Skip to content

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:

Cargo.lock — one of the eight entries, verbatim
[[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:

Real output — cargo 1.98.0
$ 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.lock file 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.

cargo install

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:

cargo install --locked bacon

--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.lock does not affect the consumers of your package, only Cargo.toml does that.

Cargo FAQ, Why have Cargo.lock in version control?

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:

Real output — cargo 1.98.0 (paths shortened)
$ 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 bisect finds 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


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