The Rust Engineering Handbook / Chapter 40
Manifests, Dependency Resolution, and Lockfiles
Trace dependency requirements into selected package identities, then choose update, lockfile, duplicate-version, source, and minimality policy for applications and libraries.
Read the selected graph, not the shorthand
The fixture’s relay-core/Cargo.toml contains this dependency:
tracefmt-v1 = {
package = "tracefmt",
path = "../../vendor/tracefmt-v1",
version = "1.4.0"
}
relay-service contains another:
tracefmt-v2 = {
package = "tracefmt",
path = "../../vendor/tracefmt-v2",
version = "2.1.0"
}
Neither key is the package identity. The keys are local dependency names used in Rust paths as tracefmt_v1 and tracefmt_v2. Both dependencies name a package called tracefmt; their versions and path sources differ. Cargo therefore selects two package IDs.
Run the evidence-producing command:
$ cargo +1.97.0 tree --workspace -d
tracefmt v1.4.0 (.../vendor/tracefmt-v1)
└── relay-core v0.1.0 (.../crates/relay-core)
└── relay-service v0.1.0 (.../crates/relay-service)
└── relayd v0.1.0 (.../apps/relayd)
tracefmt v2.1.0 (.../vendor/tracefmt-v2)
└── relay-service v0.1.0 (.../crates/relay-service)
The absolute checkout paths are incidental. The important result is two versions of one package name and the reverse paths that require them. The manifest told us acceptable locations and version constraints for direct edges. The resolver constructed a graph satisfying all active constraints. Cargo.lock recorded the selected package identities for this workspace.
Keep those three layers separate throughout the chapter: a manifest states what may be selected, resolution decides what is selected, and a lockfile records that decision for later commands. Source policy, updates, duplicate analysis, and reproducibility claims become reviewable only after the layer being changed is named.
Those layers are:
- Manifest requirement: what versions, sources, target conditions, and features a direct dependent permits.
- Resolved graph: the package versions and feature selections Cargo chose for one command context.
- Lockfile snapshot: selected package identities and integrity data that constrain later resolution when applicable.
A requirement such as codec = "0.8" is not a pin to 0.8.0. A lockfile entry is not a compatibility promise made by a reusable library. A dependency tree is not fully described by the direct [dependencies] table. Reviews that collapse these layers routinely miss transitive weight, duplicate types, update exposure, and source changes.
Dependency identity includes its source
Cargo can resolve packages from several source kinds:
- the default registry, normally crates.io;
- an alternate configured registry;
- a local filesystem path;
- a Git repository URL, optionally with a branch, tag, or revision selector.
Registry dependencies normally declare a package name and version requirement:
[dependencies]
codec = "0.8.3"
telemetry = { version = "1.4", default-features = false, features = ["std"] }
An alternate registry is named in Cargo configuration and selected on the edge:
internal-protocol = { version = "2.3", registry = "company" }
A path dependency points to a directory containing a package manifest:
relay-protocol = { path = "../relay-protocol", version = "0.1.0" }
The optional version in that form does not cause Cargo to query the registry during the local build; Cargo checks that the path package’s version satisfies the requirement. If the dependent package is published, the registry version can describe what downstream consumers should resolve while the workspace continues to use the local path. A path-only normal dependency cannot stand as a crates.io publication dependency because another machine cannot reproduce the checkout-relative location.
A Git dependency names a repository and may select rev, tag, or branch:
codec = { git = "https://example.invalid/codec", rev = "9f3c1ab" }
A branch or tag is a selector that may be resolved to a commit; do not describe a mutable branch as a permanent pin. Even a revision selection brings availability, credential, submodule, provenance, and repository-history policy that a registry package does not. Published crates on crates.io cannot use Git dependencies as their registry dependency source; a combined version plus Git or path location can support local development with a registry fallback under Cargo’s multiple-location rules.
Source is part of package identity. Two packages with the same name and version from distinct sources are not automatically interchangeable. Types from independently selected package instances can be distinct even when their source text looks alike. A source change therefore belongs in API and supply-chain review, not only in manifest cleanup.
Version requirements define ranges
Cargo’s default requirement is a caret-compatible range. The abbreviated form is preferred when it states the intent:
| Requirement | Simplified allowed range | Design meaning |
|---|---|---|
"1.4.2" |
>=1.4.2, <2.0.0 |
compatible 1.x updates |
"0.4.2" |
>=0.4.2, <0.5.0 |
compatible within 0.4.x |
"0.0.7" |
>=0.0.7, <0.0.8 |
only that patch line |
"~1.4.2" |
>=1.4.2, <1.5.0 |
patch updates only |
"=1.4.2" |
exactly 1.4.2 |
no resolver freedom |
">=1.4, <1.8" |
explicit intersection | bounded multi-minor policy |
Cargo’s compatibility treatment below 1.0 follows the leftmost non-zero component. It is more useful to state that rule than to say “pre-1.0 means anything can break,” because the latter does not describe what the resolver permits.
Requirements express compatibility expectations, not evidence that every release in the range works. A library that declares telemetry = "1.4" tells the ecosystem that any selected compatible 1.x release satisfying the graph should be usable. Test the oldest dependency versions you claim when that lower bound matters, and test current compatible versions to catch upstream regressions. Ordinary cargo test with a developer lockfile proves only the selected snapshot.
Exact requirements are sometimes justified for tightly paired packages or known upstream incompatibility. Blanket exact pins in reusable libraries reduce the resolver’s ability to find one version satisfying several dependents and can force duplicates or conflicts. Prefer a correct compatible requirement plus application-level lockfile control. If an upper bound is narrower than the next SemVer-incompatible release, record the concrete incompatibility; arbitrary defensive caps can make otherwise compatible graphs unresolvable.
Prerelease requirements follow additional matching rules and deserve explicit CI. Build metadata does not participate in version requirement matching. These details are version-sensitive; use the Cargo reference rather than reconstructing policy from general SemVer intuition.
Resolution is a constraint problem over package IDs
Cargo begins from the selected workspace packages and target context, traverses direct and transitive requirements, and chooses package versions whose requirements can be satisfied together. The output depends on manifest constraints, source availability, lockfile state, target and feature selection, resolver version, and Rust-version policy.
Resolver version 3, used by Rust 2024 workspaces when configured or inferred as documented, can prefer dependency versions compatible with the workspace packages’ declared rust-version under its fallback behavior. This is preference within the available solution space, not a substitute for MSRV testing. Dependencies may have incomplete metadata, and users can invoke Cargo with different policy. Declare rust-version, then compile and test with it.
Cargo often unifies semver-compatible requirements for the same package source onto one version. It may retain multiple semver-incompatible versions when different edges require them. It can also fail when constraints have no solution. “Cargo always picks the newest version” is too crude: an existing lockfile can preserve an older selection, Rust-version preference can affect choice, yanks and prereleases have rules, and a targeted update is intentionally conservative.
Transitive dependencies are real build inputs even when no application source imports them. They can add build scripts, proc macros, native compilation, licenses, advisories, target-specific code, and feature interactions. Review the resolved graph from product roots, not only the manifests you own.
Figure 40-1 makes the layer transition explicit. The left card contains ranges; the middle graph contains selected versions, including two legitimate incompatible versions; the right card freezes those package IDs for subsequent locked resolution. The policy badges are different because applications and libraries consume that snapshot differently.

Figure 40-1. Manifests permit a solution space, resolution selects a graph, and a lockfile records that graph; lockfile use does not erase a library’s compatibility range.
One workspace has one lockfile
Workspace members share the root Cargo.lock. A command run for one member may therefore operate within a lockfile containing packages selected for other members or command histories. The lockfile is still a resolution snapshot, not an inventory of what one final binary necessarily links. Use cargo tree -p relayd or target-specific build inspection to answer the product question.
For deployable applications, services, command-line tools, and other end products, commit Cargo.lock. Review lockfile changes with manifest changes, update intentionally, and use --locked in CI or release automation when the job must fail rather than silently change resolution. --frozen additionally prevents network access; --offline prevents network access but can still resolve differently using locally available data unless combined with the lock policy needed by the workflow.
A committed lockfile improves repeatability of dependency selection. It does not by itself guarantee bit-for-bit artifacts. The Rust toolchain, target, linker, native libraries, build scripts, environment, profiles, source availability, timestamps, and other inputs can still differ. Registry checksums help detect changed downloaded archives for locked registry packages, but lockfiles are not a complete provenance, malware, license, or reproducible-build system.
For a reusable library, preserve a broad but truthful manifest compatibility range and test it. A library’s repository may commit its lockfile to make its own CI and contributor tools consistent. Downstream consumers resolve the library into their graph; the library’s repository lockfile is not a command to use those exact transitive versions in every consumer, and a library package published to a registry does not use that development snapshot as an application lock policy.
The practical distinction is:
| Concern | Application or service | Reusable library |
|---|---|---|
| Primary promise | tested deployable graph | compatibility across declared requirements |
| Repository lockfile | commit and review | may commit for contributor/CI consistency |
| Downstream effect | controls this product’s resolution | consumers resolve their own graph |
| CI emphasis | --locked, artifact and update testing |
selected snapshot plus oldest/current compatible coverage |
| Update cadence | controlled operational change | compatibility maintenance and lower-bound accuracy |
A mixed workspace needs an explicit product policy. The root application lockfile can support deploying relayd, while library members still need compatibility jobs that are not limited to the root snapshot.
Update the graph as a reviewed change
cargo update updates the lockfile within manifest requirements. With no package argument it can reconsider the graph broadly. With a package specification it performs a conservative targeted update, changing transitive packages when required to update the named package. --precise selects an exact version or Git revision that still fits the operation’s rules.
Useful operational patterns include:
cargo update telemetry
cargo update telemetry --precise 1.6.2
cargo check --workspace --all-targets --locked
Separate three actions in review:
- Widen or change a manifest requirement. This changes the future solution space and can be a public compatibility decision.
- Refresh a lockfile within existing requirements. This changes the product’s selected graph.
- Change a source or patch. This changes where code comes from and may change package identity or provenance.
Do not hide all three in a bulk “dependency refresh.” A controlled update records the motivation, old and new package IDs, relevant release or advisory evidence, feature changes, test matrix, artifact impact, rollback path, and whether the change affects public types.
Pinning is a response to a specific risk, not a maintenance strategy by itself. An exact application lock selection can stabilize rollout while an upstream regression is investigated. A narrow library requirement can protect users from a documented incompatibility. Both should have an exit condition. Permanent old pins accumulate security and interoperability cost.
Duplicate versions are evidence, not automatically a defect
cargo tree -d reports packages present at multiple versions. The fixture deliberately retains tracefmt 1.4.0 and 2.1.0 because its two callers use incompatible APIs. This is a valid graph, but it has costs:
- both packages may compile, increasing clean build work and artifact size;
- both may bring separate transitive subgraphs, build scripts, or native code;
- the same conceptual type from two major versions may not be interchangeable;
- security, license, and support reviews must cover both;
- runtime global assumptions can conflict even when the linker accepts both;
- migrations can stall because no owner is accountable for convergence.
Not every duplicate is heavy, linked into the same product, or removable. Proc-macro, build, development, and target-specific edges can appear in a workspace tree without entering one runtime artifact. Use inverted trees to locate why a package is present:
cargo tree -i tracefmt@1.4.0
cargo tree -i tracefmt@2.1.0
Then inspect features and product scope:
cargo tree -p relayd -e features
cargo tree -p relayd --target all
The --target all view is intentionally broad and can show target-specific dependencies not built for the host. Label it as an audit view, not a final-binary inventory.
Choose one of five dispositions:
- converge by upgrading or relaxing callers onto one compatible version;
- retain because incompatibility is real and measured cost is acceptable;
- isolate the older version behind a process, plugin, or adapter boundary when type/global conflicts demand it;
- replace a dependency whose contract or maintenance no longer fits;
- remove a dependency or feature that provides insufficient value.
Do not force convergence with a patch when the APIs are semver-incompatible. Cargo cannot make two incompatible public type identities equal by choosing a clever key name.
Overrides, source replacement, and vendoring answer different questions
Cargo provides several mechanisms that are often called “pinning” even though they have different scopes.
[patch] adds or overrides candidate packages for a source during resolution. A common use is testing an unpublished fix while keeping dependent manifests pointed at their normal registry requirements. The patch is declared at the workspace root, and the patched package version must participate in a compatible solution. Treat a Git or path patch as reviewed source code with an expiration or upstreaming plan.
Dependency source replacement configures Cargo to obtain packages from another source that represents the same packaged content, such as a vendored directory or mirror. It is not intended to smuggle arbitrary changed code under the same identity. Replacement configuration belongs to the consuming environment’s Cargo configuration and carries availability and integrity policy.
cargo vendor copies registry and Git dependency source into a local directory and prints configuration for using it as a replacement source. Vendoring can support offline builds, source archival, controlled ingress, and review. It also creates obligations:
- regenerate the vendor set when the lockfile changes;
- preserve checksums and avoid casual edits to vendored files;
- scan licenses, notices, advisories, and provenance;
- decide whether the vendor tree is committed or produced as a controlled artifact;
- test offline and locked builds from a clean environment;
- review build scripts and native code, not only Rust modules.
Vendoring does not make dependencies trusted, maintained, vulnerability-free, or legally approved. It changes availability and inspection mechanics.
For a temporary local edit to a dependency, choose [patch] when the code intentionally differs and source replacement when content is meant to mirror the original source. Record the distinction so incident responders know whether the source is a fork or a copy.
Metadata turns the graph into evidence
cargo metadata --format-version 1 emits machine-readable package, target, dependency, workspace, and resolved-node data. Use it for architecture checks, license tools, build orchestration, and inventory generation rather than parsing human cargo tree output. Pass --no-deps when only workspace package manifests are relevant; omit it when the resolved dependency graph is the evidence.
Important fields include:
- package IDs, names, versions, source identifiers, and manifest paths;
- dependency requirements, rename keys, source kinds, target expressions, optionality, and requested features;
- workspace member and default-member IDs;
- target kinds, crate names, editions, and required features;
- resolve nodes and dependency links when resolution is included.
The format-version flag is required because consumers must opt into a schema version. Avoid treating opaque package-ID formatting as a stable string grammar; compare the structured identifiers emitted by the declared Cargo version.
Human investigation and automation complement each other:
cargo metadata --format-version 1 > metadata.json
cargo tree --workspace -d
cargo tree -p relayd -e features
cargo check --workspace --all-targets --locked --offline
The fixture keeps the first command’s output out of source control because absolute paths vary. Its manifests, lockfile, duplicate package names/versions, tests, and documented command supply the reproducible inputs.
An architecture policy can query metadata to reject forbidden edges such as relay-protocol -> relay-service, ensure published packages do not depend on path-only internal packages, or inventory packages with build scripts. Keep these checks focused on stable semantic fields, and pin the Cargo/tool schema used by automation.
Dependency minimality is authority minimality
Count dependencies only after understanding what they do. One small proc-macro can execute substantial code at build time. One pure Rust library can expand into dozens of transitive packages. A large, well-maintained library with disabled defaults can be cheaper and safer than a small unreviewed alternative.
For each direct dependency, ask:
- Which user or operator capability requires it?
- Is it used in the runtime, build, development, or target-specific graph?
- Which default and optional features are selected, and why?
- Does a type or trait cross the public API boundary?
- Does it run a build script, proc macro, native compiler, or code generator?
- Which targets and MSRVs does it constrain?
- What licenses, provenance, advisories, and maintenance policy apply?
- Can the standard library or a small local implementation meet the bounded need more clearly?
- Who owns updates and removal?
“Fewer crates” is not the invariant. The invariant is that every external code and build-time authority has a named purpose, bounded configuration, reviewed source, and maintained upgrade path.
Minimize features as well as package count. Disable broad defaults only when the dependency supports that configuration, then request explicit capabilities. Keep development dependencies out of normal dependency tables, and target-specific dependencies under correct target conditions. Use workspace inheritance to align versions, but do not add a dependency to packages that do not use it merely for visual consistency.
Production review should separate build-time and runtime exposure. Build scripts and proc macros execute on the host during compilation. Native dependencies affect link and deployment policy. Runtime packages affect the shipped artifact and attack surface. All belong in the supply-chain model, but their controls differ.
Failure patterns in manifest policy
The visual pin. codec = "1.4.2" is described as exact even though it permits compatible 1.x releases. Use =1.4.2 only when exactness is intentional, and prefer lockfile control for an application snapshot.
The ignored lock diff. A broad cargo update changes dozens of transitive packages with no explanation. Split or annotate updates, inspect features and source changes, and preserve a rollback snapshot.
The library lock illusion. A library passes CI against its repository lockfile and assumes its declared lower bound or every compatible release works. Add compatibility coverage beyond one snapshot.
The duplicate purge. Reviewers demand one version of every name without inspecting reverse paths, product targets, type boundaries, or migration feasibility. Diagnose before converging.
The mutable Git promise. A branch or tag is treated as immutable provenance. Prefer reviewed revisions for controlled snapshots and record how the commit was obtained and mirrored.
The permanent patch. A root patch silently carries a fork for years. Give it an owner, reason, upstream issue or long-term fork policy, and removal criterion.
The trusted vendor directory. Vendored code skips license, advisory, build-script, and provenance review because it is “local.” Locality changes availability, not trust.
The direct-only inventory. A manifest review ignores transitive packages and build dependencies. Use metadata and product-root trees.
The exact-version blanket. Every reusable-library dependency is pinned exactly to reduce change. The graph becomes harder to integrate and still requires eventual updates. State truthful compatibility and test it.
Exercise: audit a duplicate heavy crate
Level: Integrate. A service’s clean build grew after two observability libraries were added. cargo tree -d shows two major versions of a serialization package and two minor lines of a native compression binding. One duplicate appears only under a build dependency; another crosses a public API. The application commits Cargo.lock, deploys Linux containers, and supports an MSRV six releases behind current stable.
Constraints:
- do not assume every duplicate enters the runtime artifact;
- do not edit the lockfile by hand;
- preserve the public API or provide a migration plan;
- Git branches and tags are not accepted as immutable evidence;
- vendoring must preserve source and license review;
- any convergence must still satisfy manifest requirements and the MSRV.
Deliver:
- the exact
cargo treeandcargo metadatacommands used to identify package IDs, sources, features, targets, and reverse paths; - a table separating manifest requirements, current selections, and public compatibility promises;
- a product-scope analysis showing which duplicates reach the deployed binary, build host, tests, and unsupported targets;
- a cost estimate covering compile work, artifact impact, type incompatibility, native linking, security review, and ownership;
- a converge, retain, isolate, replace, or remove decision for each duplicate, with rejected alternatives;
- the manifest, lockfile, patch, or source-configuration change required—without conflating those mechanisms;
- current-stable, MSRV, feature, target, locked, and offline verification commands;
- an update and rollback record naming old and new package IDs;
- a library compatibility test beyond the application’s chosen lock snapshot;
- an owner and expiry condition for any temporary pin or patch.
Evaluate the audit by source accuracy, graph evidence, public-type awareness, operational repeatability, and explicit residual risk. A justified retained duplicate is a valid result. An unexplained forced convergence is not.
Durable conclusions
Cargo.tomldeclares acceptable dependency edges; resolution selects package IDs;Cargo.lockrecords a selected graph. Do not treat the three as interchangeable.- Package identity includes name, version, and source. Renaming a dependency key changes the local import name, not the underlying package.
- Default version requirements permit compatible ranges, including Cargo’s specific pre-1.0 rules. Exact pins are exceptional tools, not a universal library policy.
- Commit and review lockfiles for applications and deployable products. Reusable libraries must also test the compatibility range downstream resolvers may choose.
--lockedrejects unintended lockfile change; it does not make all build inputs reproducible or trusted.- Duplicate versions can be legitimate and costly. Inspect reverse paths, targets, features, public types, and artifacts before deciding to converge or retain them.
[patch], source replacement, and vendoring solve different source-control problems and carry different review obligations.cargo metadatasupplies structured graph evidence;cargo treesupplies a useful human view. Use both from the relevant product root.- Dependency minimality means every runtime and build-time authority has a justified capability, bounded configuration, reviewed source, and owned update path.
The selected graph is now inspectable and controlled. The next boundary is configuration: optional behavior, feature unification, target conditions, and public APIs must remain composable across every edge the resolver joins.
Sources and verification notes
- Cargo Reference, Specifying Dependencies, Dependency Resolution, Registries, and Package ID Specifications.
- Cargo Guide, Cargo.toml vs Cargo.lock, and Cargo command reference,
cargo update,cargo tree, andcargo metadata. - Cargo Reference, Overriding Dependencies, Source Replacement, and command reference,
cargo vendor. - Cargo Reference, Workspaces, Features, and Rust Version.
- Rust 2024 Edition Guide, Rust-version-aware Cargo resolver.
- Executable source:
examples/rust-engineering-handbook/part-07/workspace-resolution-lab/, including two localtracefmtsource packages at 1.4.0 and 2.1.0, four workspace members, one root lockfile, and no network dependencies. - Fixture provenance: resolver and duplicate-tree output was observed with Cargo 1.97.0; the fixture declares
rust-version = "1.85"and was checked on Rust 1.85.0.
Continue reading
Full table of contents