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.toml files 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 versions

The 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 crates

This 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.toml says what versions are allowed
  • Cargo.lock says 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 too

This 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.x

This 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 tree

This shows a dependency tree for the current package or workspace.

A helpful way to think about it is:

  • Cargo.toml shows declared intent
  • cargo tree shows 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 -d

This 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 -d helps 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 features

This 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_crate

This 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 build primarily cares about the normal dependency graph
  • cargo test may 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 reason

Update 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 update

And when focusing on one package:

cargo update -p some_crate

A useful mental model is:

  • change Cargo.toml when 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_crate

On 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 image with png
  • tests use image with jpeg

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 tree

Then inspect duplicates:

cargo tree -d

Then inspect features:

cargo tree -e features

Then inspect who depends on a surprising crate:

cargo tree -i some_crate

Then 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_lab

Add 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 features

Look 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 tree is 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.