Cargo Dependency Management

Cargo Features and Conditional Compilation covered toggling functionality within a crate; this tutorial covers the layer around it: managing the dependencies a project pulls in. Most of the time cargo add serde and forget it works fine, until a build breaks with "two versions of the same crate," or a cargo update silently changes behavior, or you can't figure out why a feature you didn't enable is compiled in. Understanding how Cargo resolves versions, what the lockfile does, and how features unify across the tree turns those situations from baffling to routine. This is the practical mental model, not a reference for every manifest key.


Version Requirements Are Ranges, Not Pins

A dependency line like serde = "1.0.195" does not mean "exactly 1.0.195." Cargo interprets a bare version as a caret range: the default ^1.0.195 means "any 1.x.y at or above 1.0.195, but below 2.0." This follows semver, 1.x releases promise backward compatibility, so Cargo is free to pick a newer compatible one.

[dependencies]
serde = "1.0.195"      # means ^1.0.195: >=1.0.195, <2.0.0
regex = "1"            # means ^1: >=1.0.0, <2.0.0
rand = "=0.8.5"        # exact pin: ONLY 0.8.5 (rarely what you want)

The practical consequence: your Cargo.toml specifies a compatible range, and Cargo picks the highest version in that range that satisfies everyone. This is what lets your crate and its dependencies share one copy of serde even if they wrote slightly different version requirements, as long as the ranges overlap within the same major version. An exact pin (=) opts out of this and is usually a mistake outside of pinning around a specific bug.


Cargo.toml vs Cargo.lock: Ranges vs Exact Choices

The two files answer different questions, and confusing them is the root of a lot of dependency confusion:

  • Cargo.toml declares the ranges you accept (serde = "1"). You edit it.
  • Cargo.lock records the exact version resolved for every crate in the tree (serde 1.0.210). Cargo generates it.
Cargo.toml:  serde = "1"          # "any 1.x is fine"
Cargo.lock:  serde v1.0.210       # "this exact build used 1.0.210"

The lockfile makes builds reproducible: everyone who checks out the repo gets the identical version set, not "whatever was newest that day." cargo update is the command that re-resolves within your ranges and rewrites the lockfile, running it is when versions actually change, not a plain cargo build.

Gotcha: the "commit the lockfile?" answer depends on the crate type. Commit Cargo.lock for binaries/applications (you want reproducible, tested builds of the exact thing you ship). Don't commit it for libraries (downstream consumers do their own resolution, and a committed lockfile is ignored by anyone depending on you anyway). Committing it for a library gives a false sense of control, it only governs your dev builds, never your users'. Getting this backwards is a common repo-hygiene mistake.


The Duplicate-Version Situation

Cargo's rule: it unifies dependencies that share a major version, but allows incompatible majors to coexist. If crate A needs rand ^0.7 and crate B needs rand ^0.8, those ranges don't overlap (different majors in semver's 0.x rules), so Cargo compiles both rand 0.7 and rand 0.8 into your binary.

your-app
├── crate-a → rand ^0.7   ┐
│                          ├── two DIFFERENT rand crates in the build
└── crate-b → rand ^0.8   ┘

Gotcha: two major versions of one crate coexisting is legal but causes a specific, confusing error: a type from rand 0.7 is not the same type as the one from rand 0.8, even with the identical name. Passing a rand 0.7::StdRng where a rand 0.8::StdRng is expected fails with "expected struct StdRng, found struct StdRng", the names match but the crate versions differ. When you see an error where two types look identical but won't unify, run cargo tree -d (the -d shows duplicates), it lists every crate compiled at multiple versions and which dependency pulled each in, so you can bump one to align them.


Feature Unification Across the Tree

Cargo computes the feature set for each crate as the union of features requested by everyone who depends on it. If your crate uses tokio without the fs feature but another dependency enables tokio/fs, tokio is built with fs for the whole build, features are additive and unified, not per-consumer.

# you wrote:
tokio = { version = "1", features = ["rt"] }
# but some dependency also pulls: tokio = { features = ["fs", "net"] }
# → tokio is compiled with rt + fs + net (the union)

This is why a feature you never asked for can end up compiled in, and why features are required to be purely additive (enabling one must never remove or change behavior). The practical upshots: (1) you can't disable a feature another crate enabled, so don't rely on a feature being off; (2) cargo tree -f and cargo tree -e features show who activates what. This is the whole-graph version of the feature-unification point from the cargo-features tutorial.


Dependency Kinds and Where They Belong

SectionForCompiled into your shipped binary?
[dependencies]normal runtime depsyes
[dev-dependencies]tests, benches, examplesno
[build-dependencies]used by build.rs (Build Scripts)no (build-time only)
[dependencies] + optional = trueenabled by a feature flagonly if the feature is on
[workspace.dependencies]shared versions across a workspaceper member use

Putting a dep in the right section matters: a test-only crate in [dependencies] bloats your build and shipped artifact for no reason, that's what [dev-dependencies] is for. In a multi-crate workspace (Modules and Project Organization), declare shared versions once in [workspace.dependencies] and reference them with foo.workspace = true in each member, so every crate stays on the same version without repeating the number.


Key Takeaways

  • A bare version (serde = "1") is a caret range (^1: any compatible 1.x), not an exact pin. Cargo picks the highest version satisfying every crate's range, which is how the tree shares one copy.
  • Cargo.toml declares accepted ranges (you edit it); Cargo.lock records the exact resolved versions for reproducible builds (Cargo generates it; cargo update re-resolves). Commit the lockfile for binaries, not for libraries.
  • Cargo unifies crates sharing a major version but lets incompatible majors coexist, so two versions of one crate can compile together, producing "expected X, found X" errors. Diagnose with cargo tree -d.
  • Features are unified as the union across all consumers and must be purely additive; a feature a dependency enables is on for the whole build and you can't turn it off. Inspect with cargo tree -f.
  • Use the right dependency section: [dev-dependencies] for tests, [build-dependencies] for build.rs, optional = true for feature-gated deps, and [workspace.dependencies] to share versions across a workspace.