The Cargo Guide
Lockfiles and Reproducibility
Why Cargo.lock Exists
Cargo resolves dependency requirements like serde = "1" into exact package versions. Cargo.lock records those exact choices so future builds keep using the same resolved graph instead of re-resolving to whatever compatible versions happen to be newest at that moment.
A useful mental model is:
Cargo.tomlexpresses version policyCargo.lockrecords the concrete dependency graph actually chosen
For example, this manifest is intentionally flexible:
[dependencies]
serde = "1"
regex = "1"But once Cargo resolves it, Cargo.lock pins the exact versions for repeatable builds.
Manifest Policy vs Lockfile Reality
Beginners often think Cargo.toml alone determines a completely reproducible build. It does not. Cargo.toml defines what versions are allowed, while Cargo.lock records which exact versions were selected.
Conceptually:
Cargo.toml -> acceptable version ranges
Cargo.lock -> exact resolved versionsThat distinction matters because a flexible manifest without a lockfile can lead to different concrete builds over time.
What Reproducibility Means in Practice
In Cargo workflows, reproducibility usually means that the same package resolves to the same dependency versions and therefore builds against the same graph across machines or over time.
A lockfile is one of the main tools that makes this possible.
Typical local workflow:
cargo build
cargo test
git add Cargo.lockTypical CI expectation:
cargo build --locked
cargo test --lockedThe broad idea is simple: the lockfile reduces surprise.
A Small Example Package
Suppose you create a package like this:
cargo new lock_demo
cd lock_demoAnd add dependencies:
[package]
name = "lock_demo"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = "1"
regex = "1"A simple library function might look like this:
use regex::Regex;
pub fn has_number(s: &str) -> bool {
Regex::new(r"\d").unwrap().is_match(s)
}The first build resolves and records exact versions:
cargo buildAfter that, Cargo.lock becomes part of the package's reproducibility story.
Application vs Library Norms
A common Cargo convention is that applications usually commit Cargo.lock, while libraries have historically been more mixed in practice because library consumers do not use the library author's lockfile when the library is used as a dependency.
A practical mental model is:
- application: the lockfile usually represents the actual shipped build graph, so committing it is usually expected
- library: the lockfile can still be useful for local development and CI, even though downstream users resolve their own graphs when they depend on the library
This is why you will often see application repositories treat Cargo.lock as essential source-controlled infrastructure.
Why Libraries Still Care About Cargo.lock
Even though a published library's downstream users resolve their own dependency graphs, the library author still benefits from a lockfile locally.
It helps with:
- stable CI behavior
- team consistency during development
- debugging regressions against a known graph
- controlled updates through explicit lockfile changes
So the lockfile is not only for final applications. It is also useful during library maintenance.
Workspace Lockfiles
In a Cargo workspace, all packages share a common Cargo.lock file at the workspace root.
Example layout:
my_workspace/
āāā Cargo.toml
āāā Cargo.lock
āāā app/
ā āāā Cargo.toml
ā āāā src/main.rs
āāā core/
āāā Cargo.toml
āāā src/lib.rsWorkspace manifest:
[workspace]
members = ["app", "core"]
resolver = "3"This shared lockfile matters because the workspace is developed as one coordinated graph rather than as isolated packages.
What --locked Does
The --locked flag tells Cargo to use the existing lockfile exactly as-is and fail if Cargo would need to change it.
Examples:
cargo build --locked
cargo test --locked
cargo check --lockedA useful practical interpretation is:
- if
Cargo.lockis missing, Cargo errors - if dependency resolution would change
Cargo.lock, Cargo errors
This makes --locked a strong fit for CI and any workflow that wants deterministic dependency selection.
What --offline Does
The --offline flag tells Cargo not to access the network.
Examples:
cargo build --offline
cargo test --offlineThis is useful when:
- you are on a machine without network access
- you want to ensure the build uses only locally available dependencies
- you are testing offline or hermetic workflow assumptions
A critical subtlety is that offline mode is not just "slower online mode without downloads." It can lead to different behavior because Cargo must restrict itself to already-available local data.
What --frozen Does
The --frozen flag combines the effect of --locked and --offline.
Examples:
cargo build --frozen
cargo test --frozenA helpful mental model is:
--locked: do not change the lockfile--offline: do not touch the network--frozen: enforce both at once
This is especially common in tightly controlled CI or release workflows.
Using cargo fetch Before Going Offline
If you want reliable offline builds, it is often useful to prefetch dependencies first.
cargo fetch
cargo build --offlineIf a lockfile already exists, cargo fetch downloads the dependencies needed for that lockfile. If no lockfile exists, it will generate one before fetching.
This makes cargo fetch a good preparatory step before disconnecting from the network or moving into a more restricted build environment.
A Practical Offline Workflow
A simple team-friendly pattern is:
cargo fetch
cargo build --locked
cargo test --lockedThen later, in an offline environment:
cargo build --frozen
cargo test --frozenThe key idea is to separate two concerns:
- first ensure the graph and artifacts are locally available
- then enforce reproducibility and no-network guarantees
Reproducible Local Builds
For local development, reproducibility usually means you can come back later, rebuild, and still be using the same dependency graph unless you explicitly decide to update it.
A stable local flow often looks like this:
cargo build
cargo test
git diff Cargo.lockIf Cargo.lock changed unexpectedly, that is usually a signal worth understanding before committing.
Reproducible CI Builds
CI systems usually want dependency resolution to be explicit and stable.
A common pattern is:
#!/usr/bin/env sh
set -e
cargo build --locked
cargo test --locked
cargo doc --lockedAnd in more restricted pipelines:
#!/usr/bin/env sh
set -e
cargo build --frozen
cargo test --frozenThese workflows reduce the chance that a pipeline silently picks up a newer compatible dependency version than the one developers tested locally.
Lockfile Churn
Lockfile churn means frequent or noisy changes to Cargo.lock that are larger or less intentional than they need to be.
This often happens when:
- dependency updates are allowed to drift casually
- multiple branches update overlapping parts of the dependency graph
- new features or platforms cause resolution changes
- teams update manifests without a clear lockfile policy
A lockfile diff is not automatically a problem, but it is often a meaningful artifact that deserves review rather than being treated as noise.
Reducing Lockfile Churn
A few habits reduce unnecessary lockfile churn.
Keep updates intentional:
cargo updateOr update one package deliberately:
cargo update -p regexThen run the normal verification loop:
cargo test
cargo buildThe key idea is that the lockfile should change because you meant to change the graph, not because resolution drifted in an uncontrolled way.
Minimal Lockfile Included in Packages
When a package with a binary executable target is published, Cargo can include a Cargo.lock file in the packaged crate. That packaged lockfile is useful for reproducible installation of that published package.
A practical consequence appears with cargo install.
By default:
cargo install some_binary_crateCargo may ignore the packaged lockfile and recompute dependency resolution.
To force use of the packaged lockfile when available:
cargo install --locked some_binary_crateThat is useful when you want installation to reproduce the dependency graph that was packaged with the published binary crate.
Why Packaged Lockfiles Matter More for Binary Crates
Binary crates are closer to finished applications than reusable library crates. That makes their packaged lockfiles especially relevant when users install them directly.
A helpful mental model is:
- library dependency use: downstream packages resolve their own graph
- installed binary package: using the packaged lockfile can better reproduce the author's intended tested graph
Team Workflows Around Cargo.lock
Healthy team workflows usually treat the lockfile as a shared artifact rather than a disposable byproduct.
Typical team practices include:
- committing
Cargo.lockfor applications and workspaces - reviewing lockfile diffs when dependencies change
- using
--lockedin CI - updating dependencies intentionally rather than incidentally
A simple workflow might look like this:
cargo update -p serde
cargo test
git add Cargo.toml Cargo.lock
git commit -m "Update serde"This turns dependency movement into an explicit team event.
Merge Conflicts in Cargo.lock
Teams frequently encounter lockfile merge conflicts because many branches may change dependency resolution.
A common practical resolution pattern is:
git checkout --theirs Cargo.lock
cargo build
cargo testOr sometimes:
rm Cargo.lock
cargo generate-lockfile
cargo testThe exact strategy depends on context, but the main lesson is that the correct conflict resolution is usually the one that restores a valid, tested lockfile for the merged manifest state.
Generating or Regenerating the Lockfile
Cargo can generate or rebuild a lockfile directly.
cargo generate-lockfileAnd dependency updates can also create or refresh it:
cargo updateThese commands are useful when:
- the lockfile is missing
- you intentionally want to refresh the resolved graph
- you need to recover from a broken or stale lockfile state
How Long-Lived Environments Behave Differently
On machines that build Rust projects over long periods, Cargo's caches matter almost as much as the lockfile.
A lockfile may describe a stable dependency graph, but a long-lived environment still depends on cached registry, git, and package data being available locally when using restricted modes like --offline or --frozen.
That is why cache behavior is part of reproducibility in practice, especially for developer laptops, CI runners, and long-lived build hosts.
Automatic Cache Garbage Collection
Cargo now includes automatic garbage collection for its global caches. The purpose is to stop Cargo's home directory from growing without bound over time.
At a practical level, this means long-lived environments may eventually lose old cached dependency artifacts if they have not been used for long enough.
That is usually a good thing for disk usage, but it means reproducibility assumptions should not quietly depend on caches living forever.
Why Automatic Cache GC Affects Reproducibility Planning
Suppose a machine built a project months ago and later tries to rebuild it with strict offline assumptions.
If old cached artifacts have been garbage-collected, this may affect whether --offline or --frozen workflows still succeed without a prior refresh.
That means a strong reproducibility strategy should rely on more than hope that old caches still exist. Teams that need robust offline or hermetic behavior often use one or more of:
cargo fetchas a preparation step- vendoring
- controlled CI cache warming
- mirrors or internal registries
A Practical CI Pattern with Locking and Prefetching
A more deliberate CI sequence can look like this:
cargo fetch
cargo build --locked
cargo test --lockedAnd for stricter environments:
cargo fetch
cargo build --frozen
cargo test --frozenThe first line ensures required dependencies are locally available. The later lines enforce graph stability.
Common Beginner Mistakes
Mistake 1: treating Cargo.lock as redundant because dependencies are already listed in Cargo.toml.
Mistake 2: assuming --locked and --offline mean the same thing.
Mistake 3: using --offline without first ensuring dependencies are locally available.
Mistake 4: treating lockfile diffs as meaningless noise.
Mistake 5: assuming long-lived machines will keep cached dependency data forever.
Mistake 6: thinking library authors never benefit from a lockfile.
The lockfile remains useful for local development, CI, and debugging even when downstream library consumers do not use that exact file.
Hands-On Exercise
Create a small package and walk through the lockfile workflow deliberately.
Start here:
cargo new lockfile_lab
cd lockfile_labAdd dependencies:
[dependencies]
serde = "1"
regex = "1"Build once to create the lockfile:
cargo buildNow try a reproducibility-oriented sequence:
cargo test --locked
cargo fetch
cargo build --offline
cargo test --frozenThen inspect what changed:
git diff Cargo.lockThis exercise helps make the differences between normal builds, locked builds, offline builds, and frozen builds concrete.
Mental Model Summary
A strong mental model for lockfiles and reproducibility in Cargo is:
Cargo.tomldefines allowed dependency rangesCargo.lockrecords the exact resolved graph- applications and workspaces usually rely heavily on committed lockfiles
- libraries still benefit from lockfiles for local development and CI
--lockedforbids lockfile drift--offlineforbids network access--frozenenforces bothcargo fetchprepares dependencies for restricted or offline workflows- packaged lockfiles are especially relevant for published binary crates and
cargo install --locked - lockfile churn should be intentional and reviewable
- automatic cache garbage collection means long-lived environments should not assume caches remain available forever
Once this model is stable, Cargo reproducibility becomes much easier to manage deliberately rather than by accident.
