Cargo.toml explained: dependencies, versions and features
A practical guide to Cargo.toml: package metadata, dependency version requirements (semver), features, dev-dependencies, profiles and Cargo.lock.
Every Rust project is described by a Cargo.toml file at its root, written in TOML (a simple key = value format with [sections]). Here's a realistic one for a small web service:
[package]
name = "arena-api"
version = "0.3.1"
edition = "2024"
rust-version = "1.85"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1.40", features = ["rt-multi-thread", "macros"] }
anyhow = "1"
regex = { version = "1", optional = true }
[dev-dependencies]
pretty_assertions = "1"
[features]
default = ["search"]
search = ["dep:regex"]
[profile.release]
lto = true
[package]
name and version identify the crate; edition selects the language edition (2024 is current; editions are opt-in language updates, and crates of different editions work together). rust-version declares the oldest compiler you support.
Version requirements are semver ranges
serde = "1.0.200" does not mean exactly 1.0.200. It's a caret requirement: any version compatible with it under semantic versioning, here >=1.0.200, <2.0.0. Cargo picks the newest matching version. The rules:
| Requirement | Allows |
|---|---|
"1.4.2" (same as "^1.4.2") |
>=1.4.2, <2.0.0 |
"0.4.2" |
>=0.4.2, <0.5.0 (for 0.x, the minor number is the breaking one) |
"~1.4.2" |
>=1.4.2, <1.5.0 |
"=1.4.2" |
exactly 1.4.2 |
"*" |
anything (not allowed on crates.io) |
cargo add serde --features derive edits the file for you; cargo update moves to the newest allowed versions; cargo tree shows the full dependency graph.
Cargo.lock
Cargo.lock records the exact version of every dependency that was resolved, so every build (yours, CI's, a colleague's) uses the same code. Commit it. (Older advice said not to commit it for libraries; current guidance is to commit it for all projects.)
Features
Features are named switches that turn on optional code or optional dependencies. Using a crate's feature: tokio = { version = "1", features = ["full"] }. Defining your own: the [features] table above makes regex an optional dependency pulled in only by the search feature, and default lists features enabled unless a user opts out with default-features = false. In code, #[cfg(feature = "search")] includes an item only when the feature is on.
Features must be additive: enabling one may add functionality but must never break code that doesn't use it, because Cargo unifies features across the whole dependency graph.
Other sections
[dev-dependencies]: only for tests, examples and benchmarks.[build-dependencies]: forbuild.rsscripts.[profile.release]/[profile.dev]: optimization settings (opt-level,lto,codegen-units,debug,overflow-checks,panic = "abort").[workspace]: several crates in one repository sharing oneCargo.lockandtarget/directory.[lints]: set rustc and clippy lint levels for the whole package.
Common mistakes
- Pinning exact versions (
=1.2.3) everywhere, which blocks bug fixes and causes conflicts. Use caret requirements and rely onCargo.lock. - Forgetting a feature a crate needs (
serdewithoutderive,tokiowithoutmacrosfor#[tokio::main]): the error is a missing macro or item. - Treating
0.xminor bumps as safe:0.4to0.5is a breaking change.
Example
#[derive(Debug, PartialEq, PartialOrd)]
struct Version {
major: u64,
minor: u64,
patch: u64,
}
fn parse(v: &str) -> Option<Version> {
let mut parts = v.split('.').map(|p| p.parse::<u64>().ok());
let (major, minor, patch) = (parts.next()??, parts.next()??, parts.next()??);
Some(Version { major, minor, patch })
}
// Cargo's default (caret) requirement: same leftmost non-zero number, and not older.
fn caret_allows(req: &str, candidate: &str) -> bool {
let (Some(r), Some(c)) = (parse(req), parse(candidate)) else {
return false;
};
let compatible = if r.major > 0 {
c.major == r.major
} else if r.minor > 0 {
c.major == 0 && c.minor == r.minor
} else {
c.major == 0 && c.minor == 0 && c.patch == r.patch
};
compatible && c >= r
}
fn main() {
let checks = [
("1.4.2", "1.4.9"),
("1.4.2", "1.9.0"),
("1.4.2", "2.0.0"),
("1.4.2", "1.4.1"),
("0.4.2", "0.4.7"),
("0.4.2", "0.5.0"),
("0.0.3", "0.0.4"),
];
for (req, candidate) in checks {
let verdict = if caret_allows(req, candidate) { "allowed" } else { "rejected" };
println!("{:<9} {candidate:<6} {verdict}", format!("\"{req}\""));
}
println!("debug build: {}", cfg!(debug_assertions));
}
Output:
"1.4.2" 1.4.9 allowed
"1.4.2" 1.9.0 allowed
"1.4.2" 2.0.0 rejected
"1.4.2" 1.4.1 rejected
"0.4.2" 0.4.7 allowed
"0.4.2" 0.5.0 rejected
"0.0.3" 0.0.4 rejected
debug build: true