Cargo.lock: what it records, who reads it, and the command that ignores it¶
Level: 201 · working knowledge
One line: Cargo.toml says what you will accept and Cargo.lock says what you got — every cargo command writes it, build, run and test obey it, cargo install ignores it unless you say --locked, and the one place it never reaches is the people who depend on your crate.
One entry, read line by line¶
cargo add serde@1.0.229 --features derive in a fresh project writes one line into Cargo.toml and eight [[package]] blocks into Cargo.lock. This is the block for serde_derive, a crate the manifest never names:
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
| Line | What it fixes |
|---|---|
name / version |
this crate at this exact version — no range anywhere in the file |
source |
where it came from: the crates.io index, a git URL with a commit, or nothing at all for a local path |
checksum |
the SHA-256 of the .crate archive; Cargo refuses a download whose hash differs |
dependencies |
the edges of the graph, by name only — each one resolves to another [[package]] in the same file |
The checksum is the line that earns the file its name. Run the same cargo add on any machine, on any day the version still exists, and the block comes out byte-identical — the hash above is the one on Aaron Seilis's slide ↗ from a Vancouver meetup two weeks before this page was written, on a different laptop. Two people with the same lockfile do not have "the same dependencies" in the loose sense; they have the same bytes.
The file opens with # This file is automatically @generated by Cargo. and version = 4, and the lockfile format version is the one number in it you never set.
Who writes it, who reads it, who does not¶
Every cargo command writes it. Not just build. A fresh cargo new leaves Cargo.toml and src/; the first command of any kind adds the lockfile:
$ cargo new anycmd && cd anycmd && ls
Cargo.toml src
$ cargo tree
anycmd v0.1.0 (…/anycmd)
$ ls
Cargo.lock Cargo.toml src
cargo tree compiled nothing and still resolved the graph and recorded it. So a lockfile is never "missing because nobody built yet"; it is missing only if somebody deleted it or ignored it.
build, run, check, test, clippy read it. Given a lockfile whose versions still satisfy the manifest, they use exactly those versions and touch the network only for crates not yet downloaded.
cargo install does not. The Cargo Book, on installing a published binary crate:
By default, the
Cargo.lockfile that is included with the package will be ignored. This means that Cargo will recompute which versions of dependencies to use, possibly using newer versions that have been released since the package was published.
So the bacon or cargo-nextest you install today is built against dependency versions the author never tested, unless you write what the bacon and cargo-nextest pages already write without saying why:
--locked means use the packaged Cargo.lock, and fail rather than change it. The trade is the book's own: you get the author's tested set, and you do not get any dependency fix published since.
Your dependents do not, either. This is the one people get backwards, and the Cargo FAQ puts it plainly:
this determinism can give a false sense of security because
Cargo.lockdoes not affect the consumers of your package, onlyCargo.tomldoes that.
When somebody adds your library, Cargo reads your manifest — the ranges — and resolves them fresh against their own lockfile. Your lockfile is for you: your tests, your CI, your examples. It pins nothing for anyone else.
Cargo.toml wins¶
The lockfile is a cache of one decision the manifest permitted. Change the manifest so the cached answer is no longer inside the range, and the next command recomputes it without being asked:
$ cargo add rand@0.8.0 # manifest: rand = "0.8.0" lockfile: rand 0.8.8
$ sed -i '' 's/^rand = .*/rand = "0.9"/' Cargo.toml
$ cargo tree
Updating rand v0.8.8 -> v0.9.5 (available: v0.10.2)
reqdemo v0.1.0 (…/reqdemo)
└── rand v0.9.5
No cargo update was run. The lockfile said 0.8.8, the manifest now said "anything 0.9.x", and the manifest is the source of intent. (available: v0.10.2) is Cargo telling you there is a newer incompatible version it did not take — the rule for why 0.10 is incompatible with 0.9 is on Two versions of one crate.
Moving the lockfile on purpose: cargo update¶
| Command | What moves |
|---|---|
cargo update |
every package, to the newest version inside its requirement |
cargo update serde |
serde — and its dependencies only if serde cannot move without them |
cargo update -n |
nothing; prints what the two above would do |
cargo update serde --precise 1.0.228 |
serde to exactly that version, up or down, if the manifest allows it |
cargo update --breaking |
across a major version, rewriting the manifest — nightly only |
The single-package form is conservative in a specific sense, and a downgrade shows it:
```text title="Real output — cargo 1.98.0, manifest says serde = { version = "1", features = ["derive"] }" $ cargo update serde --precise 1.0.228 Updating crates.io index Downgrading serde v1.0.229 -> v1.0.228 Downgrading serde_core v1.0.229 -> v1.0.228 (available: v1.0.229) Downgrading serde_derive v1.0.229 -> v1.0.228 (available: v1.0.229) Downgrading syn v3.0.4 -> v2.0.119
$ cargo update -n Locking 4 packages to latest Rust 1.98.0 compatible versions Updating serde v1.0.228 -> v1.0.229 Updating serde_core v1.0.228 -> v1.0.229 Updating serde_derive v1.0.228 -> v1.0.229 Updating syn v2.0.119 -> v3.0.4 warning: not updating lockfile due to dry run
One crate was named and four moved, because `serde_derive 1.0.228` needs `syn 2` where `1.0.229` needs `syn 3`. "Only the package I asked for" is the goal; the dependencies it drags are the minimum needed to get there. `-n` is the form to run on the way into a project: it says what has moved upstream and writes nothing, which is exactly how the [devenv](../devenv/README.md) shell hook uses it.
**The trap in `--precise`: it cannot leave the requirement.** `cargo add` writes the *full* current version — `serde = "1.0.229"` — which is the caret range `>=1.0.229, <2.0.0`. Ask the lockfile to go one patch lower and Cargo refuses, correctly, because the manifest forbids it:
```text title="Real output — cargo 1.98.0, manifest as cargo add wrote it"
$ cargo update serde --precise 1.0.228
error: failed to select a version for the requirement `serde = "^1.0.229"`
candidate versions found which didn't match: 1.0.228
location searched: crates.io index
required by package `lockdemo v0.1.0 (…/lockdemo)`
The transcript before this one worked only because the manifest had been loosened to serde = "1" first. Keep the two jobs separate: the range is the manifest's, the point inside it is the lockfile's, and --precise only ever moves the point.
Commit it¶
cargo new already tracks it: the .gitignore it writes has one line, /target. Leave it that way — for an application, and for a library too.
That second half is the advice that changed. For years the rule was binaries commit the lockfile, libraries ignore it, on the reasoning that a library's lockfile does nothing for its users (true, see above) so why keep one. The current Cargo FAQ ↗ lists what it does for the library's own repository:
git bisectfinds the commit that broke something, rather than the day a dependency changed under every commit- CI fails only because of a new commit, not because of a release on crates.io overnight
- verifying a minimum supported Rust version against dependency versions that still support it
- snapshot tests of human-readable output — error messages, help text — that carry no compatibility guarantee
The cost the FAQ names is merge conflicts, and they are real: two branches that each ran cargo update conflict on hundreds of lines. Resolve by taking either side and running cargo update -w — the --workspace form re-locks only your own packages and leaves everything else as recorded.
If you are coming from another language¶
Python. Cargo.toml is pyproject.toml and Cargo.lock is uv.lock (or poetry.lock, or a requirements.txt compiled from a requirements.in). Every point above transfers, including the two that surprise people: a library's lockfile is ignored by everyone who pip installs it, and a tool installed globally — pipx install, uv tool install — resolves its dependencies fresh at install time, which is cargo install without --locked. What Rust changes is that the lockfile is not optional: pip will happily run a project with no lockfile at all, forever; Cargo writes one on the first command and reads it on every one after, so the question is only whether you commit it.
ABAP. There is no lockfile because there is no range. A transport request carries exact object versions and the target system gets exactly those; nothing is resolved at import time, so nothing needs recording. The nearest thing to Cargo.lock is the transport log itself — the record of which versions actually arrived, kept beside the objects rather than inside them. What ABAP has that Cargo does not is a single system of record for everyone: your lockfile is yours alone, and the people who depend on your crate resolve against theirs.
See also¶
- Adding a dependency — what
cargo addwrites into the manifest, and the operators that widen or narrow the range - Two versions of one crate — the compatibility rule that decides whether two requirements share one entry in this file or get two
- Vendoring, and the
[patch]table — when the source of a package is a folder in your repo rather than the registry - Pinning the toolchain — the same range-versus-answer idea one layer up, for the compiler
- The Cargo Book —
Cargo.lock↗ — the two files, side by side - The Cargo Book —
cargo update↗ — every flag, including--recursiveand the git-revision form of--precise
No generated output block on this page, deliberately: every transcript here is a property of Cargo and of crates.io on a given day, not of a program, and the answer-key runner compiles single .rs files with no dependencies at all — which is the one thing this page cannot demonstrate.
Po polsku¶
Dwa pliki dzielą się rolami i warto mieć tę parę w głowie jako jedno zdanie: Cargo.toml mówi, na co się zgadzasz (zakres wersji), a Cargo.lock zapisuje, co dostałeś (dokładne wersje, źródło i sumę kontrolną każdego pakietu, także tych pośrednich, których manifest w ogóle nie wymienia). Suma kontrolna to linia, która nadaje plikowi sens: ten sam cargo add na dowolnej maszynie i dowolnego dnia daje wpis identyczny co do bajtu — hash serde_derive z tej strony jest tym samym hashem, który pokazał prelegent na slajdzie dwa tygodnie wcześniej, na innym laptopie.
Trzy rzeczy, których nie widać z samego pliku. Po pierwsze, każde polecenie cargo go zapisuje — nawet cargo tree, które nic nie kompiluje — więc brak pliku blokady nigdy nie znaczy „nikt jeszcze nie budował”. Po drugie, cargo build, run i test go respektują, ale cargo install go ignoruje: narzędzie instalowane dzisiaj buduje się z wersjami zależności, których autor nigdy nie testował, chyba że dopiszesz --locked — i to jest powód, dla którego strony o bacon i cargo-nextest piszą cargo install --locked. Po trzecie, i to jest rzecz najczęściej rozumiana na odwrót: twój Cargo.lock nie dociera do nikogo, kto zależy od twojego crate'a. Konsument czyta twój manifest, czyli zakresy, i rozwiązuje je na nowo względem własnego pliku blokady. Twój lock jest dla ciebie — dla twoich testów, CI i przykładów.
Manifest jest źródłem intencji i wygrywa: zmień wymaganie tak, żeby zablokowana wersja wypadła poza zakres, a następne polecenie przeliczy graf bez pytania. Świadome ruszanie pliku blokady to cargo update (wszystko w granicach zakresów), cargo update serde (jeden pakiet, plus tylko te zależności, bez których nie da się go ruszyć — w przykładzie jeden nazwany pakiet pociągnął cztery, bo nowsze serde_derive potrzebuje syn 3), cargo update -n (na sucho, nic nie zapisuje) i cargo update serde --precise 1.0.228 (dokładnie ta wersja, w górę albo w dół). Pułapka w --precise: nie wyjdzie poza zakres z manifestu, a cargo add wpisuje pełną bieżącą wersję, więc serde = "1.0.229" znaczy ^1.0.229 i zejście o jeden patch niżej kończy się błędem failed to select a version for the requirement. Najpierw rozluźnij manifest (serde = "1"), potem ruszaj punkt wewnątrz zakresu.
Plik blokady commituj — dla programów zawsze, a dla bibliotek też, bo zalecenie się zmieniło. Dawna reguła brzmiała „binarki tak, biblioteki nie”, skoro lock biblioteki i tak nie działa na jej użytkowników; dzisiejsze FAQ Cargo wylicza, co robi dla samego repozytorium biblioteki: git bisect znajduje commit, a nie dzień, w którym zależność zmieniła się pod wszystkimi commitami; CI pada tylko z powodu nowego commita, a nie nocnego wydania na crates.io; da się sprawdzić minimalną wspieraną wersję Rusta. Kosztem są konflikty scalania — rozwiązuj je biorąc dowolną stronę i uruchamiając cargo update -w, które przelicza tylko twoje własne pakiety.
Szukaj po polsku: plik blokady zależności · czy commitować Cargo.lock · cargo install --locked · cargo update --precise · Cargo.lock does not affect consumers