What Is Cargo.toml vs Cargo.lock

When to Use Which

Use Cargo.toml to define dependencies and Cargo.lock to lock exact versions for reproducible builds.

The "It Works on My Machine" Trap

You pull the latest changes from your teammate's branch. You run cargo build. The build fails with E0432 (unresolved import) inside a dependency you haven't touched. You check the code. It's identical to yours. You check your own terminal. Your build passes.

The difference isn't in the source code. It's in the dependency tree. Your teammate's machine resolved a newer version of a transitive dependency that changed a function signature. Your machine is still using an older version cached from last week. This is the classic "it works on my machine" failure, and it happens because the project lacks a mechanism to freeze the dependency graph.

Rust solves this with two files: Cargo.toml and Cargo.lock. Confusing them leads to broken builds, dependency hell, and wasted time. Knowing which one to edit, which one to commit, and how they interact is the foundation of reproducible Rust development.

Constraints vs. Receipts

Cargo.toml defines what you want. Cargo.lock records what you got.

Think of Cargo.toml as a shopping list. You write "Milk, 1 gallon". You don't specify the brand, the farm, or the batch number. You give the shopper flexibility to pick any milk that fits the description. Cargo.lock is the receipt. It says "Milk, Brand X, Lot 123, $3.50". If you go back tomorrow and show the receipt, the store knows exactly what you bought last time. If you show the shopping list, you might get a different brand.

In Rust terms:

  • Cargo.toml contains version constraints. serde = "1.0" means "any version greater than or equal to 1.0.0, but less than 2.0.0". It allows Cargo to pick the best version that satisfies all constraints.
  • Cargo.lock contains exact versions. It pins serde to 1.0.193. It also pins every transitive dependency. If serde depends on serde_derive, the lock file records the exact version of serde_derive too.

Cargo.toml is for humans. You edit it to add dependencies, bump versions, or configure features. Cargo.lock is for machines. Cargo generates it. You never edit it by hand.

# Cargo.toml
# This declares intent. "1.0" is a constraint, not a pin.
[dependencies]
# Shorthand for ^1.0. Allows 1.0.0 up to <2.0.0.
serde = "1.0"
# Cargo.lock (excerpt)
# This records reality. Exact versions for everything.
[[package]]
name = "serde"
version = "1.0.193"
source = "registry+https://github.com/rust-lang/crates.io-index"

[[package]]
name = "serde_derive"
version = "1.0.193"
source = "registry+https://github.com/rust-lang/crates.io-index"

Treat Cargo.lock as the source of truth for the build. Cargo.toml is the source of truth for the project's requirements.

How Cargo Resolves Dependencies

When you run cargo build, Cargo follows a strict protocol.

  1. Cargo reads Cargo.toml and collects all direct dependencies.
  2. Cargo fetches metadata from the registry to find transitive dependencies.
  3. Cargo runs the resolver. It calculates a dependency tree where every version satisfies every constraint.
  4. If Cargo.lock exists, Cargo checks if the lock file is compatible with the current Cargo.toml.
    • If compatible, Cargo uses the versions in the lock file. No network requests for versions. No surprises.
    • If incompatible (e.g., you bumped a version in Cargo.toml), Cargo updates the lock file to match the new constraints.
  5. If Cargo.lock does not exist, Cargo creates one with the resolved tree.

This protocol ensures that once a lock file is generated, every subsequent build uses the exact same dependency graph until you explicitly change something.

Convention aside: cargo generate-lockfile creates the lock file without compiling. Use this in CI pipelines to validate that the lock file exists and is consistent before triggering a heavy build step. It saves minutes of compilation time if the lock file is missing or corrupted.

Libraries Float, Binaries Lock

The most critical decision in Rust dependency management is whether to commit Cargo.lock to version control. The answer depends on your package type.

Rust packages are either libraries or binaries. Libraries are consumed by other crates. Binaries are end products like CLI tools, web servers, or applications.

The Library Case

When you publish a library, your consumers will have their own Cargo.toml and their own Cargo.lock. Your library is just one node in their dependency tree.

If you commit Cargo.lock for a library, you force your consumers to use your exact dependency tree. This causes version conflicts. Imagine your library pins log = "0.4.17". A consumer of your library also depends on tracing, which pins log = "0.4.20". Cargo cannot satisfy both lock files simultaneously. The build fails.

Libraries must allow their consumers to resolve dependencies in the context of the consumer's project. The consumer's lock file is the authority. Your library should only declare constraints in Cargo.toml.

Convention aside: cargo init --lib automatically adds Cargo.lock to .gitignore. Cargo knows the convention and sets the defaults for you. If you see Cargo.lock in .gitignore, you are likely building a library.

The Binary Case

When you build a binary, you are the final consumer. There is no downstream crate to resolve dependencies. You need reproducible builds. Every developer, every CI runner, and every deployment must use the exact same dependency tree.

If you don't commit Cargo.lock for a binary, two developers might have different versions of a dependency. One might have a bug that was fixed in a patch release. The other might have a breaking change that hasn't hit their machine yet. This leads to subtle bugs that only appear in production.

Binaries must commit Cargo.lock. This guarantees that cargo build produces the same artifact everywhere.

Convention aside: cargo init --bin does not add Cargo.lock to .gitignore. If you are building a binary and Cargo.lock is ignored, you are risking non-reproducible builds. Check your .gitignore and remove the entry if you are shipping a binary.

Libraries export flexibility. Binaries demand certainty.

Pitfalls and Conventions

Editing Cargo.lock Manually

Never edit Cargo.lock by hand. The file contains internal checksums, source metadata, and complex dependency relationships. Manual edits can corrupt the file or create an inconsistent state that Cargo cannot parse.

If you need to change a dependency version, update Cargo.toml and run cargo update. Cargo will regenerate the lock file correctly.

If you edit Cargo.lock manually, you're fighting the tool. Use cargo update instead.

The Update Trap

cargo update updates all dependencies to the latest versions that satisfy the constraints in Cargo.toml. This can introduce breaking changes if a dependency released a new minor version with API changes.

Use cargo update -p crate-name to update a single dependency. This limits the blast radius. You can test the update in isolation.

# Update only serde and its transitive dependencies
cargo update -p serde

# Update everything (dangerous in large projects)
cargo update

Version Shorthand

serde = "1.0" is shorthand for serde = "^1.0". The caret means "compatible with". It allows minor and patch updates but blocks major updates.

Convention aside: Write 1.0, not ^1.0. The community considers the caret redundant noise. Both forms compile and behave identically. The explicit caret is rarely used in modern Rust codebases.

Transitive Dependencies

Your Cargo.toml only lists direct dependencies. Cargo.lock lists everything, including transitive dependencies. A transitive dependency is a dependency of a dependency.

If serde depends on proc-macro2, and proc-macro2 has a security vulnerability, you need to update proc-macro2. You can't edit Cargo.toml to fix this because proc-macro2 isn't there. You run cargo update -p proc-macro2. Cargo updates the transitive dependency and rewrites Cargo.lock.

This is why Cargo.lock matters. It tracks the entire tree, not just the top level.

Counter-intuitive but true: the more dependencies you have, the more likely a transitive update will break your build. Lock files protect you from this by freezing the tree until you choose to update.

Cargo Workspaces

Workspaces allow multiple crates to share a single Cargo.lock. This is common in monorepos. The workspace root contains Cargo.toml with a [workspace] section and a single Cargo.lock.

All crates in the workspace share the lock file. This ensures consistency across the entire project. If one crate needs serde = "1.0" and another needs serde = "1.0", they get the same version.

Workspaces simplify dependency management for large projects. They also make the lock file even more critical. If you ignore the lock file in a workspace, you risk version drift between crates.

Trust the workspace lock file. It keeps the monorepo coherent.

Decision Matrix

Use Cargo.toml to declare your project's intent and version constraints. Edit this file to add dependencies, bump versions, or configure features.

Use Cargo.lock to guarantee reproducible builds by recording the exact resolved dependency tree. Never edit this file by hand; let Cargo generate it.

Commit Cargo.lock to version control when you are building a binary, a CLI tool, or a web server. This ensures every deploy and every developer gets the exact same dependency graph.

Ignore Cargo.lock in version control when you are publishing a library crate. Your consumers need to resolve dependencies in the context of their own projects, and a committed lock file will cause version conflicts.

Run cargo update when you want to refresh the lock file to pull in newer patch or minor versions within your constraints. Run cargo update -p crate-name when you need to bump a specific dependency without touching the rest of the tree.

Use cargo generate-lockfile in CI pipelines to validate the lock file exists without compiling. This catches missing lock files early and saves build time.

When in doubt, check the package type. Binary? Lock it. Library? Let it float.

Where to go next