Skip to content
beginner Phase 9 · Modules, Crates, and Cargo Workspaces

Dependencies in Cargo.toml

Declare crate versions, features, and dev-only dependencies correctly.

40m
0 problems
Topic Progress 0%

Reading Cargo.toml: What a Version Requirement Actually Promises

A Cargo.toml manifest is a declaration of intent, and the [dependencies] section is the part that decides which code gets compiled into your crate:

[package]
name = "billingd"
version = "0.4.1"
edition = "2021"
rust-version = "1.74"

[dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
tracing = "0.1"
mylib-core = { path = "../core" }          # local crate in a monorepo
internal-fork = { git = "https://github.com/acme/fork", branch = "fix" }

Each entry is a requirement, not a download instruction: serde = "1.0" asks Cargo for "the newest version of serde that is >= 1.0.0 and compatible under semver," and the resolver picks one concrete version for your whole build. The shorthand string is sugar for version, and the table form adds options — features to turn on optional capabilities, default-features = false to strip the defaults, optional = true to make the dependency itself feature-gated (the next topic), plus path/git/branch/rev for sources other than crates.io.

The version grammar is where correctness lives. cargo add serde --features derive writes the entry without you hand-editing, but you should be able to read what it wrote:

Requirement Accepts Rejects
"1.2.3" (same as "^1.2.3") 1.2.3 … <2.0.0 1.2.2, 2.0.0
"^0.4.1" 0.4.1 … <0.5.0 0.4.0, 0.5.0
"~1.2.3" 1.2.3 … <1.3.0 1.2.2, 1.3.0
"=1.2.3" exactly 1.2.3 anything else
"*" anything (reproducibility: nothing — avoid)

The caret is the default and the right default: for 1.x.y and above, the minor and patch may move freely while the major is fixed; for 0.x.y, the minor acts as the major — ^0.4.1 allows 0.4.9 but not 0.5.0, because pre-1.0 crates use the minor for breaking changes. This single rule explains most "my build broke after cargo update" posts: the requirement was "0.3" in a 0.x ecosystem where 0.4 was breaking.

Good habits, in order of value:

  1. Declare the minimum you actually need, not the newest you have — "1.0" says "any 1.x works," which is a claim about your own compatibility, and CI can verify it with cargo update -Z minimal-versions (nightly) or by testing against the floor explicitly.
  2. Let cargo add edit the manifest. It resolves the current version, writes correct table syntax, and avoids the classic hand-edit typo (version = "serde").
  3. Keep [package] metadata honest — rust-version (the MSRV) is now checked by the resolver: a dependency requiring a newer compiler fails at resolve time with a clear message instead of an obscure compile error.

When something surprising lands in your build, cargo tree shows the resolved graph and why each crate is present: cargo tree -i serde prints the reverse path ("tokio → serde_util → serde"), which is how you find the transitive dependency dragging in the version you did not ask for.

Non-crates.io sources deserve their own caution because they change the supply-chain story: a git dependency is pinned in the lockfile by commit hash (the branch is just a pointer consulted at update time), so cargo update -p that-crate is the moment a branch tip moves under you — pinning with rev = "<sha>" makes that explicit. A path dependency is never published as-is: cargo publish rewrites it to a registry requirement if a version is present, and refuses without one. [patch] lets you locally override a registry crate during development ([patch.crates-io] mylib = { path = "../mylib" }) without editing anyone's manifest — the change is local to your workspace and its lockfile.

dev-dependencies, build-dependencies, and Target-Specific Sections

Not every dependency belongs in your users' builds. Cargo gives each dependency a purpose, and the section you put it in decides who pays for it:

[dependencies]                     # everything that compiles your lib/bin
serde = "1.0"

[dev-dependencies]                 # tests, examples, benches ONLY
criterion = "0.5"
tempfile = "3"
proptest = "1"

[build-dependencies]               # runs inside build.rs, not in the final artifact
cc = "1"

[target.'cfg(unix)'.dependencies]  # only on matching platforms
libc = "0.2"

[target.'cfg(windows)'.dependencies]
winapi = { version = "0.3", features = ["winuser"] }

dev-dependencies is the one that changes your dependency footprint the most. A crate declared there is available to cargo test, cargo bench, and examples/, but never compiled into a library you publish — so downstream users do not download criterion because your benchmarks use it. The practical test: if only #[cfg(test)] code, tests/, or benches/ mention it, it is a dev-dependency; if src/ mentions it, it is a real one. Moving proptest from [dependencies] to [dev-dependencies] typically deletes a dozen transitive crates from every consumer's build.

build-dependencies are a separate universe again: they compile and run during build.rs (generating bindings, compiling C via cc, probing the system), and their versions are resolved independently — your cc 1.0 in build-dependencies and a dependency's cc 0.9 can coexist because they never appear in the same artifact graph. Target-specific sections let one manifest serve several platforms; cargo tree --target x86_64-pc-windows-gnu shows which subset a given triple pulls in, which is the fastest way to audit a cross-platform build.

Two commands complete the picture:

cargo tree                      # the resolved dependency graph, indented by depth
cargo tree -i tokio             # who depends on tokio (reverse edges)
cargo tree --duplicates         # two versions of the same crate side by side
cargo add --dev proptest        # add to [dev-dependencies] correctly

--duplicates deserves a moment: seeing aho-corasick 0.7 and 1.3 in one build means extra compile time and binary size, and the fix is usually bumping a requirement so both unify. Since the resolver picks one version per semver-compatible range across the whole graph, duplicates only appear across incompatible ranges (0.7 vs 1.3) — the resolver can never merge those, so the fix must come from an upstream bump, not from your manifest.

A build.rs makes the [build-dependencies] section concrete:

// build.rs — runs once, before the crate compiles
fn main() {
    println!("cargo:rustc-env=BUILD_ID={}", std::env::var("GIT_HASH").unwrap_or_default());
    cc::Build::new().file("c/zstd.c").compile("zstd");   // needs cc from [build-dependencies]
}

The script runs at build time with build-dependencies linked into it, and any cargo: lines it prints configure the crate's compilation (env vars, link flags, rerun triggers). None of that machinery reaches your users' artifacts — which is the through-line of this topic: each section draws a boundary around who compiles what and when.

Duplicate detection also has a time dimension: cargo tree --duplicates shows how many versions of the same crate are compiled, and each extra one is real wall-clock time and binary size. Since the resolver unifies everything within a semver-compatible range, a duplicate libc 0.2 almost always means two majors of some higher crate each pinning a different incompatible range — the fix is bumping the older dependent, an upstream PR if needed. A 200-crate dependency graph with two tokio majors is a graph that will surprise your build times.

The interview angle: "how do you keep a test-only crate out of your library's users' builds?" — [dev-dependencies], because those are compiled only for this package's own tests and examples and are not part of the published dependency list.

Cargo.lock, cargo update, and Reproducible Builds

Cargo.toml records what you allow; Cargo.lock records what you got — every resolved crate, exact version, source, and checksum:

[[package]]
name = "serde"
version = "1.0.197"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69c9b7e0a5b1c1b1d4b4c..."

On cargo build, the lockfile wins: if it satisfies every requirement in the graph, that exact set compiles — no resolver run, no drift. Delete it (or run cargo update) and the resolver picks the newest versions matching your requirements, rewriting the lock.

Which means the policy question — commit it or not — has a clean answer for the two cases that matter:

Project Commit Cargo.lock? Why
Binary / app / service Yes every deploy, every CI job, every machine builds the identical artifact set
Library Usually yes your CI then tests the versions you pinned; dependents re-resolve from your Cargo.toml, so the lock never constrains them
Published crate It is ignored by consumers either way cargo publish ships Cargo.toml, not your resolution

That last row is the key insight interviewers look for: a lockfile governs only the workspace that owns it. Anyone depending on your library resolves their own versions from the requirements you declared, so committing the lock in a library repo cannot pin the ecosystem — it only makes your own CI deterministic. The historical advice ("libraries must not commit the lock") was about not forcing versions on others, a fear the design never supported.

Day-to-day operations:

cargo update                       # newest versions still matching every requirement
cargo update -p serde              # move one crate only
cargo update -p serde --precise 1.0.195   # pin back to a known-good release
cargo build --locked               # CI mode: fail if the lockfile would change
cargo tree -i some-crate           # explain why a version is present

--locked (often with --offline in hermetic environments) is what turns "it worked on my machine" into a build error you can act on: if Cargo.toml and Cargo.lock disagree, the build refuses instead of silently re-resolving. The complement is a deliberate update rhythm — a scheduled cargo update -p for security advisories (cargo audit), and a full cargo update treated as a normal pull request with a changelog and a green test run, rather than an emergency made under pressure.

The lockfile is also what makes offline and vendored builds possible: cargo fetch downloads every locked crate into ~/.cargo/registry, after which cargo build --offline compiles with no network at all — the setup for reproducible CI images, air-gapped environments, and cargo vendor (which copies sources into the repo and rewrites .cargo/config.toml to use them). None of that works without the lock, because "every crate, exact version, exact checksum" is precisely what an offline builder needs to locate and verify.

The mental model to keep: requirements are policy (in Cargo.toml, reviewed by humans, semver-bounded), the lock is evidence (regenerated by Cargo, committed for reproducibility, constraints only this workspace). Keeping policy and evidence in separate files, and knowing which one cargo update touches, is the whole topic — and with dependencies settled, Cargo Workspaces for Multi-Crate Repos shows how one lock and one target directory serve several crates at once.

Quiz

1. Your manifest says `foo = "0.4.1"`. Which version may the resolver select?

Question 1 options

2. What is the effect of putting `criterion` in `[dev-dependencies]`?

Question 2 options

3. `cargo tree -i tokio` prints a path ending in tokio. What does it tell you?

Question 3 options

4. A teammate runs `cargo update`, the build breaks, and you must return to the previous exact versions. What do you do?

Question 4 options

Flashcards

Question

What does `serde = "1.0"` mean to Cargo, and what is the default semver operator?

Answer

Shorthand for `serde = { version = "1.0" }` with an implicit caret: `^1.0` = `>=1.0.0, <2.0.0`. The resolver picks the newest version in range across the whole graph — one version per compatible range.

Question

How does caret behave for `0.x` versions?

Answer

`^0.4.1` = `>=0.4.1, <0.5.0` — for pre-1.0 crates the *minor* is the breaking boundary, so 0.5 is excluded. Other operators: `~1.2.3` (>=1.2.3, <1.3.0), `=1.2.3` (exact), `*` (anything — avoid).

Question

What is the difference between `[dependencies]`, `[dev-dependencies]`, and `[build-dependencies]`?

Answer

`dependencies` — compiled into the lib/bin and part of the published graph. `dev-dependencies` — tests/examples/benches only, never shipped to consumers. `build-dependencies` — used by `build.rs` (e.g. `cc`), resolved separately and absent from the final artifact.

Question

What does `Cargo.lock` contain, when does it win, and should a binary commit it?

Answer

Exact name/version/source/checksum for every resolved crate. It wins on every build when it satisfies the requirements, making builds reproducible. Binaries and apps should commit it; for libraries it only pins your own CI — dependents re-resolve from your Cargo.toml.

Question

How do you update one crate, pin it back, and make CI refuse silent changes?

Answer

`cargo update -p serde` moves just that crate within its range; `cargo update -p serde --precise 1.0.195` pins an exact version; `cargo build --locked` (often with `--offline`) fails if Cargo.toml and Cargo.lock disagree instead of re-resolving.

Revision Notes

Key Takeaways

  • 1. A version string is a range: caret is the default, and for `0.x` crates the minor is the breaking boundary — the rule behind most "update broke my build" stories
  • 2. The manifest section decides who pays: `[dependencies]` for shipped code, `[dev-dependencies]` for tests and benches (invisible to consumers), `[build-dependencies]` for `build.rs`, target sections for platform gating
  • 3. `cargo tree -i` and `--duplicates` explain *why* a crate is in your graph and where you are compiling the same crate twice
  • 4. The resolver picks one version per compatible range across the whole graph, so duplicates only arise across incompatible majors — fixed by an upstream bump, not by your manifest
  • 5. `Cargo.lock` governs only its own workspace: commit it for reproducible builds, `--locked` in CI, and `--precise` when you need to walk a bad update back

Interview Tips

  • • "\"Explain `foo = \"0.4.1\"`\" — implicit caret with the 0.x rule (`<0.5.0`); then contrast `~`, `=`, and why `*` is unacceptable in a published manifest"
  • • "\"How do you keep test-only crates out of users' builds?\" — `[dev-dependencies]`; add that the published manifest excludes them, so nothing is fetched downstream"
  • • "\"A crate you never added appears in your build — what do you run?\" — `cargo tree -i <crate>` to print the reverse path, then bump or patch the offending transitive dependency"
  • • "\"Your CI build differs from a laptop's — what do you check?\" — Cargo.lock committed and `--locked` on the CI command; explain the range (Cargo.toml) vs exact pin (Cargo.lock) split"

Cheat Sheet

Dependencies in Cargo.toml

Manifest sections:

[dependencies]                          # shipped: lib/bin compile against it
serde = { version = "1.0", features = ["derive"], default-features = false }
local = { path = "../core" }            # monorepo
fork  = { git = "...", branch = "fix" }

[dev-dependencies]                      # tests / examples / benches only
criterion = "0.5"

[build-dependencies]                    # for build.rs
cc = "1"

[target.'cfg(unix)'.dependencies]       # platform-gated
libc = "0.2"

Version grammar:

Written Means
"1.2.3" / "^1.2.3" >=1.2.3, <2.0.0
"0.4.1" >=0.4.1, <0.5.0 (0.x: minor = major)
"~1.2.3" >=1.2.3, <1.3.0
"=1.2.3" exact
"*" anything — never ship it

Commands:

cargo add serde --features derive      # edit the manifest correctly
cargo add --dev proptest
cargo tree -i tokio                    # who pulled this in
cargo tree --duplicates                # wasted compile time
cargo update -p serde --precise 1.0.195  # targeted rollback
cargo build --locked                   # CI: fail if the lock would change

Lockfile: Cargo.toml = allowed range (policy), Cargo.lock = exact resolution (evidence). Commit it for binaries/apps (and normally for libraries, since dependents re-resolve anyway).

Continue Learning