RustArena

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

Common mistakes

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

Practice it