How Cargo Thinks About Dependencies and Builds
Cargo looks, at first glance, like a package manager in the mold of npm or pip: a manifest file lists dependencies, a lockfile pins versions, and a command builds the project. Underneath that familiar surface, Cargo makes a specific set of design choices, most visibly around how it resolves features across an entire dependency graph and how it treats a multi-crate project as a single build unit, that shape how real Rust projects are structured.
This page is the conceptual anchor for the rest of the Cargo section. It covers the manifest/lockfile relationship, the mechanics of feature unification (a frequent source of confusing "why did enabling X here affect Y over there" bugs), and workspaces as the unit Cargo actually builds and resolves against, not the individual crate.
Summary
- Cargo resolves one dependency graph per workspace, records the exact result in
Cargo.lock, and unifies feature flags across every crate that graph contains. - Insight: A single, unified resolution keeps builds reproducible and guarantees that any two crates linking the same dependency link the same compiled version of it, which prevents an entire category of duplicate-symbol and ABI mismatch bugs.
- Key Concepts: manifest, lockfile, semver resolution, feature unification, workspace, resolver.
- When to Use: Understanding this model matters most when debugging "why is this feature enabled when I didn't ask for it," structuring a multi-crate project, or deciding whether a monorepo should be one workspace or several.
- Limitations/Trade-offs: Feature unification and single-graph resolution trade some isolation for guaranteed consistency; a feature enabled by one crate in a workspace genuinely does affect every other crate sharing that dependency.
- Related Topics: semantic versioning, Cargo features, publishing to crates.io, build scripts.
Foundations
Every Cargo project has two files that look similar but serve opposite purposes. Cargo.toml, the manifest, expresses intent: it lists dependencies as version ranges (serde = "1.0" means "any 1.x release, compatible by semver rules") along with package metadata, features, and build profiles. Cargo.lock, the lockfile, records fact: the exact version of every crate, direct and transitive, that Cargo actually resolved and built the last time it ran.
This split exists to solve a real tension. A manifest that pinned exact versions everywhere would make every dependency update a manual, crate-by-crate chore. A manifest with no pinning at all would make builds non-reproducible, silently pulling in a newer transitive dependency between one build and the next. Cargo's answer is to let the manifest stay loose while the lockfile stays exact, and to treat the lockfile as the source of truth for what actually gets compiled until someone deliberately runs cargo update.
Cargo follows Rust's semantic versioning conventions when resolving a range like "1.0": it means "greater than or equal to 1.0.0, less than 2.0.0," on the assumption that a 1.x release never breaks the public API a 1.0 consumer relies on. A leading 0.x version is treated more strictly, since pre-1.0 crates use the 0.MINOR.PATCH position the way stable crates use MAJOR.MINOR, so "0.8" means "compatible with 0.8.x" specifically, not "0.x."
The practical convention that follows from this split: applications and workspace roots almost always commit Cargo.lock to version control, because a deployed binary needs the exact same dependency versions every time it is built. Libraries published to crates.io generally do not commit their own lockfile, because a library's job is to compose correctly into whatever dependency graph its consumer already has, not to dictate one.
Mechanics & Interactions
Cargo's dependency resolver does not run once per crate. It runs once per workspace, walking every member crate's manifest and every transitive dependency, and produces a single resolved graph recorded in one shared Cargo.lock. This single-graph model is the root cause of the behavior developers usually find most surprising about Cargo: feature unification.
If crate A in a workspace depends on serde without the derive feature, and crate B depends on serde with derive enabled, both crates get serde compiled with derive turned on. Cargo does not compile two different copies of serde, one with the feature and one without, because features are additive by design (turning one on is not supposed to remove capability), so the resolver simply takes the union of every feature request for a given dependency across the whole graph and builds that one configuration everywhere.
# crate-a/Cargo.toml
[dependencies]
serde = "1.0" # no explicit features
# crate-b/Cargo.toml
[dependencies]
serde = { version = "1.0", features = ["derive"] }
# Result: serde is compiled with `derive` enabled
# for BOTH crate-a and crate-b, even though crate-a never asked.This is a deliberate trade-off, not an oversight. It guarantees that any two crates in the graph link the exact same compiled version of a shared dependency, which avoids duplicate-symbol errors and, for crates with unsafe code relying on a specific memory layout, avoids genuinely unsound ABI mismatches. The resolver introduced in the 2021 edition (resolver = "2") narrowed the scope of unification somewhat, so that features requested only by dev-dependencies or only for a different target platform do not leak into the normal build, but unification within the graph that actually gets compiled together is still the rule, not the exception.
[workspace.dependencies] exists specifically to make this unification predictable instead of accidental. Declaring a dependency once at the workspace root and having every member reference it with dep = { workspace = true } means the whole team sees one place where a version or feature set is decided, instead of discovering the actual unified configuration only after Cargo resolves it.
Advanced Considerations & Applications
A workspace is Cargo's unit of coordinated building, and understanding it as a first-class concept, not just "a folder with multiple crates in it," explains most of the structural decisions in real multi-crate Rust projects. A workspace root Cargo.toml with a [workspace] table lists member crates; if that root file has no [package] section of its own, it is a virtual workspace, common in monorepos that ship several independent binaries and libraries with no single "main" crate.
Every member of a workspace shares one Cargo.lock, one resolved dependency graph, and one target/ build output directory. This has real operational consequences: cargo build --workspace compiles every member together, sharing already-compiled dependency artifacts across them, which is typically faster than building each crate in isolation, but it also means a compile error in one member's dependency graph can block cargo check from succeeding for the whole workspace, even for members that do not touch the broken code path.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
| Single crate + modules | Simplest mental model; one manifest, one lockfile | No independent versioning or publish cadence per module | Small to medium apps and libraries |
| Cargo workspace (single repo) | Shared lockfile guarantees consistent versions; fast incremental builds | Feature unification affects every member; large graphs slow full rebuilds | Related crates (API, CLI, shared domain) released together |
| Multiple repositories | Full isolation; independent release cadence per crate | Shared types drift out of sync; harder to test cross-crate changes together | Teams that own separate release schedules for genuinely separate products |
| Polyglot monorepo tooling (Bazel, Nx) | Handles non-Rust languages alongside Rust | Loses Cargo-native ergonomics; steeper tooling investment | Mixed-language monorepos beyond pure Rust |
The choice between a workspace and separate repositories is really a choice about how tightly coupled a set of crates' release cycles should be. Crates that always need to be tested and deployed together (an API server and its shared domain types) benefit from a workspace's single lockfile, which makes "these versions were tested together" true by construction rather than by convention. Crates with genuinely independent lifecycles, especially ones published separately to crates.io with their own semver, often work better as separate repositories precisely to avoid the feature-unification coupling a workspace introduces.
Publishing complicates the workspace model slightly, because crates.io has no concept of a workspace: cargo publish operates on one package at a time, and any path = "../other-crate" dependency must resolve to a published version number before that package can be published. publish = false in a member's manifest marks internal-only crates so cargo publish --workspace-style automation, or CI scripts iterating members, skips them safely.
Common Misconceptions
- "
cargo updatechanges what my code depends on" - it only rewritesCargo.lockwithin the version ranges already declared inCargo.toml; adopting a new major version still requires editing the manifest by hand. - "Each crate in a workspace gets its own dependency resolution" - resolution happens once for the whole workspace, which is exactly why enabling a feature in one member can change the compiled behavior of a shared dependency used by another.
- "Feature unification is a bug Cargo should fix" - it is a deliberate consistency guarantee; the alternative, compiling multiple differently-featured copies of the same dependency, would risk duplicate symbols and unsound ABI mismatches for unsafe crates.
- "Libraries should always commit Cargo.lock like applications do" - libraries generally omit it, because their job is to compose into whatever graph a consumer already has, not to force a specific set of transitive versions on every downstream user.
- "A workspace is just a folder with several Cargo.toml files in it" - it is a specific Cargo concept with a shared lockfile, a shared resolved graph, and a shared
target/directory, which changes build and feature behavior compared to treating each crate as fully independent. - "Version ranges like
\"1.0\"mean exactly version 1.0.0" - by Rust's semver convention,"1.0"means any compatible 1.x release; an exact pin requires the"=1.0.0"syntax explicitly.
FAQs
What is the actual difference between Cargo.toml and Cargo.lock?
Cargo.toml declares dependency version ranges and metadata, the intent. Cargo.lock records the exact versions Cargo resolved and built, the fact. Builds use the lockfile when one is present and still satisfies the manifest's ranges.
Why doesn't cargo update touch Cargo.toml?
Because its job is narrower: refresh the lockfile to the newest versions that still satisfy the ranges already declared in the manifest. Adopting a version outside those ranges, like a new major version, requires editing Cargo.toml directly.
Should I commit Cargo.lock to version control?
Yes for applications and workspace roots, so every build and deploy uses identical dependency versions. Generally no for standalone libraries published to crates.io, since a library should compose into whatever graph its consumer already has.
What exactly is feature unification?
When two or more crates in the same resolved graph depend on the same dependency with different features enabled, Cargo compiles one configuration with the union of all requested features, rather than separate copies per feature set.
Why does enabling a feature in one crate affect another crate I didn't touch?
Because Cargo resolves one dependency graph per workspace, not one per crate. If both crates depend on the same version of a shared dependency, they get the same compiled configuration of it, including any feature either one requested.
Does resolver = "2" turn off feature unification?
No, it narrows it. It stops features requested only by dev-dependencies or only for other target platforms from leaking into the normal build, but crates that are actually compiled together within one target still get unified features.
What is a virtual workspace?
A workspace root Cargo.toml containing only a [workspace] table, with no [package] section of its own. Common for monorepos that ship several independent crates with no single primary package at the root.
Do workspace members share a target directory?
Yes, one target/ directory at the workspace root holds build artifacts for every member, which lets shared dependencies compile once and get reused across members instead of once per crate.
Can I publish a whole workspace to crates.io at once?
Not as a single operation; crates.io has no workspace concept. cargo publish runs per package, and path dependencies between members must resolve to published version numbers before the dependent package can publish.
When should I use a workspace instead of separate repositories?
When the crates involved are always tested and released together, like an API server and its shared domain types, so a single lockfile guarantees "these versions were tested together" by construction. Independently released crates often fit separate repositories better.
What does workspace.dependencies actually solve?
It centralizes version and feature declarations for a dependency in one place at the workspace root, so members reference it with dep = { workspace = true } instead of each repeating (and potentially disagreeing on) the same version string.
Why does one broken member sometimes block the whole workspace build?
Because cargo check/build --workspace resolves and often compiles the shared graph together; a compile error in a dependency shared by multiple members can block the workspace-wide command even for members that never call the broken code directly.
Related
- Cargo.toml Explained - manifest fields, dependency tables, and version syntax
- Dependency Management -
cargo update,cargo audit, and supply chain hygiene - Features & Optional Dependencies - feature composition and
cfggating - Workspaces - practical workspace layout and commands
- Cargo Basics -
new,build,test, and everyday commands
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.