The Cargo Guide
Dependency Resolution and Graph Behavior
Why Dependency Resolution Matters
Cargo does not just read dependency declarations and fetch crates one by one. It has to build a single coherent dependency graph that satisfies version requirements, source constraints, feature choices, target conditions, and workspace context.
A useful mental model is:
- your
Cargo.tomlfiles describe constraints - Cargo's resolver tries to find a graph that satisfies those constraints
- the result is recorded in
Cargo.lock
That means resolution is the process that turns dependency declarations into an actual build plan.
At a high level, resolution answers questions like:
- which exact version of each dependency should be used?
- can the graph use one version of a crate, or does it need multiple?
- which features get enabled on shared dependencies?
- which dependencies are relevant for this command and target?
- what stays fixed because of the lockfile?
The Resolver at a High Level
Cargo's resolver works by trying to satisfy dependency requirements across the whole graph, not just one package at a time.
Suppose you have this manifest:
[dependencies]
serde = "1"
regex = "1"That looks simple, but each of those crates may have their own dependencies, and those dependencies may themselves need specific versions or features.
Conceptually, the resolver is trying to construct something like this:
my_package
āāā serde -> exact resolved version
āāā regex -> exact resolved version
āāā transitive dependencies -> exact resolved versionsThe important beginner insight is that resolution is global. Cargo is not deciding dependency versions in isolation.
Direct vs Transitive Dependencies
A direct dependency is one you name in your own manifest.
[dependencies]
regex = "1"A transitive dependency is one pulled in by something you depend on.
For example, if your package depends on a crate that depends on another crate, that second crate is transitive from your point of view.
A useful mental model is:
- direct dependency: you asked for it explicitly
- transitive dependency: it entered the graph because something else asked for it
This matters because many surprising resolution outcomes come from transitive dependencies rather than the direct dependencies you can see immediately in Cargo.toml.
A Small Example Graph
Imagine a package with this manifest:
[dependencies]
rand = "0.8"Even though you only wrote one dependency, Cargo may build a larger graph behind the scenes.
Conceptually:
my_package
āāā rand
āāā rand_core
āāā getrandom
āāā other transitive cratesThis is why dependency trees often feel larger than the manifest suggests. The graph you build is usually much bigger than the declarations you wrote directly.
Lockfile-Driven Resolution
The result of dependency resolution is stored in Cargo.lock.
A useful mental model is:
Cargo.tomlsays what versions are allowedCargo.locksays what versions were actually selected
Example manifest:
[dependencies]
serde = "1"
regex = "1"Conceptually, after resolution the lockfile fixes concrete versions such as:
serde -> one exact version
regex -> one exact version
all transitive dependencies -> exact versions tooThis matters because subsequent builds normally reuse the lockfile result instead of resolving from scratch every time. That is what makes builds stable over time.
Why the Lockfile Changes How Resolution Feels
Without a lockfile, a flexible dependency requirement could resolve to different exact versions at different times.
With a lockfile, Cargo usually keeps using the previously chosen versions until you intentionally update them.
This is why the same manifest can feel both flexible and reproducible at the same time:
- flexible because the requirement allows a range
- reproducible because the lockfile records one concrete selection
Resolver Versions
Cargo supports resolver versions that affect how features are unified across the graph.
A workspace or package may specify a resolver version in the manifest.
Example:
[workspace]
members = ["app", "core"]
resolver = "3"Or in older examples you may see:
[workspace]
members = ["app", "core"]
resolver = "2"A good mental model is:
- the resolver version is not just a syntax detail
- it changes how Cargo computes feature activation across shared dependencies
For learners, the most important practical idea is that newer resolver behavior exists to avoid some of the unintuitive feature unification behavior that older graphs could produce.
Feature Unification at a High Level
Feature unification means that if the same dependency appears in multiple places in the graph with different requested features, Cargo may combine those feature requests into one effective feature set for that dependency.
Suppose your package depends on the same crate in two ways:
[dependencies]
log = { version = "0.4", features = ["serde"] }
[dev-dependencies]
log = { version = "0.4", features = ["std"] }Conceptually, Cargo may end up building log with both features enabled together rather than treating them as entirely separate wishes.
That is the core intuition behind feature unification: shared dependencies often receive the union of requested features.
Why Feature Unification Can Surprise People
Many developers expect the dependency graph to behave exactly like the separate tables in Cargo.toml.
But the built graph is often more unified than the manifest layout suggests.
For example, if one part of the graph enables a dependency feature and another part enables a different feature on the same resolved package, the final build can reflect both.
That is why a dependency graph shown by Cargo may look more merged than the manifest you wrote.
A Concrete Feature Unification Example
Suppose you have:
[dependencies]
image = { version = "0.25", default-features = false, features = ["png"] }
[dev-dependencies]
image = { version = "0.25", default-features = false, features = ["jpeg"] }Conceptually, the build graph for commands that include dev dependencies may end up seeing image with both png and jpeg enabled.
That does not mean the manifest was wrong. It means the graph is being resolved as one coherent build rather than as totally isolated declarations.
Resolver Versions and Feature Behavior
Resolver versions matter largely because they influence how aggressively features are unified across different dependency contexts.
A practical summary for learners is:
- older resolver behavior can unify features more broadly than you might expect
- newer resolver behavior is designed to reduce some of that over-unification in situations like host dependencies, target-specific dependencies, and dev/build separation
The exact internal mechanics are less important at first than the practical lesson: the resolver version changes the shape of the final graph.
Duplicate Versions in the Graph
Cargo sometimes resolves multiple versions of the same crate in one dependency graph.
For example, one part of the graph may need foo 1.x, while another part still requires foo 2.x, and there may be no single version that satisfies both.
Conceptually:
my_package
āāā crate_a
ā āāā foo v1.x
āāā crate_b
āāā foo v2.xThis is called duplicate versions in the graph.
It is not always wrong. Sometimes it is the only valid resolution. But it can increase compile time, binary size, and conceptual complexity.
Why Duplicate Versions Happen
Duplicate versions usually appear when different parts of the dependency graph require incompatible version ranges.
For example:
[dependencies]
crate_a = "1"
crate_b = "1"Even though your own manifest looks simple, crate_a and crate_b may depend on different incompatible lines of the same transitive crate.
This is one reason dependency trees become surprising: the duplication often originates downstream from your direct dependencies.
Using cargo tree to See the Graph
The main command for inspecting the resolved graph is cargo tree.
cargo treeThis shows a dependency tree for the current package or workspace.
A helpful way to think about it is:
Cargo.tomlshows declared intentcargo treeshows a view of the resolved graph
That makes cargo tree one of the most important commands for understanding dependency behavior.
Using cargo tree to Find Duplicates
To focus on duplicate versions, use:
cargo tree -dThis is especially useful when build times or binary size feel unexpectedly large.
A learner-friendly interpretation is:
- if the same crate appears at multiple versions,
cargo tree -dhelps reveal that - once you know which crate is duplicated, you can investigate which dependency path introduced each version
Using cargo tree for Features
To inspect feature flow, Cargo supports feature-oriented tree views.
cargo tree -e featuresThis can help explain why a dependency ended up with features enabled that you did not expect.
For example, if you thought a crate only had one feature enabled, the feature tree may show that another part of the graph also requested additional features.
This is one of the best tools for diagnosing feature unification surprises.
Using Inverted Views to Trace Causes
When you want to know who pulled in a dependency or feature, an inverted tree can help.
Illustrative command pattern:
cargo tree -i some_crateThis lets you inspect what in the graph points into some_crate.
That is often more useful than staring at the full tree because it answers the practical question:
"Why is this crate here at all?"
Why Dependency Trees Become Surprising
Dependency trees become surprising for a few common reasons.
First, direct dependencies hide a much larger transitive graph.
Second, feature unification can merge requests from different parts of the build.
Third, duplicate versions may appear even when your own manifest looks clean.
Fourth, command context matters. A graph relevant to cargo build may differ from one relevant to cargo test because tests can involve dev dependencies and broader feature activity.
The deeper lesson is that the final graph reflects the whole build situation, not just one dependency line in one manifest.
Build vs Test Graph Intuition
A package can have different effective graph shapes depending on the command.
Suppose you have:
[dependencies]
regex = "1"
[dev-dependencies]
pretty_assertions = "1"Then conceptually:
cargo buildprimarily cares about the normal dependency graphcargo testmay bring in dev dependencies and feature combinations relevant to tests
This is one reason a graph seen during testing can feel larger or more surprising than the one implied by normal build dependencies alone.
Graph Explosion
Graph explosion means the dependency graph becomes much larger, more duplicated, or more feature-heavy than expected.
This may happen because:
- you added a high-level crate with many transitive dependencies
- features pulled in optional subsystems
- multiple versions of the same crate are now resolved
- several direct dependencies bring overlapping ecosystems with different version lines
Graph explosion is not just aesthetic. It can affect compile times, artifact size, cache behavior, and reasoning difficulty.
Version Skew
Version skew means related parts of the graph depend on different versions of the same crate ecosystem or conceptual layer.
For example:
crate_a -> helper v1
crate_b -> helper v2```
That skew may be harmless, or it may lead to extra duplication, more compile work, or harder debugging.
A useful mental model is:
- duplicate versions are the visible symptom
- version skew is often the underlying reasonUpdate Strategies at a High Level
When you want to change the resolved graph, there are two main levers.
First, change the manifest requirements.
Second, update the lockfile resolution.
A common command for updating is:
cargo updateAnd when focusing on one package:
cargo update -p some_crateA useful mental model is:
- change
Cargo.tomlwhen policy should change - change the lockfile when you want a different allowed concrete version
Selective Updates and Stabilizing the Graph
Suppose a transitive dependency has a fix available within the already-allowed range.
Often the manifest does not need to change at all. You may only need to update the lockfile resolution.
cargo update -p some_crateOn the other hand, if the fix is outside the allowed version range, you may need to widen or change the requirement in Cargo.toml.
This is why diagnosing resolution problems often requires looking at both the manifest and the lockfile.
Minimal Versions as a Diagnostic Idea
Cargo also has unstable minimal-version resolution modes intended to test whether declared lower bounds are honest.
Conceptually, these modes try to resolve the minimum SemVer versions that satisfy requirements instead of the greatest available compatible versions.
That can help reveal a common hidden issue:
- your manifest says a low version is acceptable
- but your code actually relies on functionality added only in a later release
This is more advanced and not part of the everyday beginner workflow, but it is useful for understanding that dependency resolution policy can be tested, not just assumed.
A Small Graph Example with Surprising Behavior
Suppose a package has:
[dependencies]
image = { version = "0.25", default-features = false, features = ["png"] }
my_tool = "1"
[dev-dependencies]
image = { version = "0.25", default-features = false, features = ["jpeg"] }At first glance, you might think:
- normal code uses
imagewithpng - tests use
imagewithjpeg
But depending on the command context and resolver behavior, the effective graph may unify more of those feature requests than you initially expected.
That is exactly the sort of graph surprise that cargo tree -e features helps explain.
A Small Code Example with Direct and Transitive Dependencies
Consider this simple library:
use regex::Regex;
pub fn has_number(s: &str) -> bool {
Regex::new(r"\d").unwrap().is_match(s)
}Manifest:
[dependencies]
regex = "1"The Rust code only mentions regex, but the actual graph contains whatever regex itself needs as transitive dependencies.
This is a good reminder that source code visibility and graph size are very different things.
Diagnosing Graph Problems Step by Step
A practical diagnosis flow often looks like this.
First inspect the graph:
cargo treeThen inspect duplicates:
cargo tree -dThen inspect features:
cargo tree -e featuresThen inspect who depends on a surprising crate:
cargo tree -i some_crateThen decide whether the fix belongs in:
- dependency version requirements
- feature flags
- workspace dependency alignment
- lockfile update strategy
This workflow is often more effective than reading manifests alone.
Common Beginner Mistakes
Mistake 1: assuming the graph is basically the same as the direct dependencies listed in Cargo.toml.
Mistake 2: assuming one dependency line means one crate instance in the build.
Mistake 3: forgetting that features may unify across shared dependencies.
Mistake 4: treating duplicate versions as impossible or always erroneous.
Mistake 5: changing manifests when the real issue is only that the lockfile needs updating.
Mistake 6: trying to understand a surprising graph without using cargo tree.
Hands-On Exercise
Create a small package and inspect its graph deliberately.
Start with:
cargo new resolve_lab --lib
cd resolve_labAdd a couple of dependencies:
[dependencies]
regex = "1"
serde_json = "1"Add a simple function:
use regex::Regex;
pub fn has_number(s: &str) -> bool {
Regex::new(r"\d").unwrap().is_match(s)
}Then run:
cargo build
cargo tree
cargo tree -d
cargo tree -e featuresLook at the contrast between:
- what you declared directly
- what Cargo actually resolved
That contrast is the fastest way to internalize how dependency resolution really works.
Mental Model Summary
A strong mental model for Cargo dependency resolution is:
- the resolver builds one coherent graph from all dependency constraints
- direct dependencies are only the visible entry points into a much larger transitive graph
- resolver versions influence how features unify across the graph
- the lockfile records the exact resolved result
- duplicate versions can appear when constraints cannot be unified into one version
- dependency trees become surprising because resolution is global, feature-aware, and command-context-sensitive
cargo treeis the main tool for seeing the resolved graph, duplicates, and feature flow- graph explosions and version skew are diagnosis problems first, and only then manifest-editing problems
Once this model is stable, dependency graphs become much easier to reason about as build artifacts rather than as mysterious side effects.
