When the default drift breaks your build
You deploy a Rust service to production. It compiled fine in CI. It runs for a week. Then a background job crashes with a panic deep inside a dependency you haven't touched. You check the logs. The error mentions a version number you didn't expect. Somewhere, a transitive dependency updated, and the new version has a regression. You need to lock that version down. Or maybe you're building a CLI tool and you want to ensure every user gets the exact same behavior, down to the byte. You need to pin a dependency to an exact version.
Cargo defaults to keeping your dependencies fresh. It pulls in compatible updates automatically. That's usually helpful. It gets you bug fixes and security patches without manual work. It also introduces risk. A new version might change behavior in a way that breaks your specific use case. A transitive update might pull in a breaking change for a niche feature. When the automatic drift causes problems, you need to stop it. You use the = operator in Cargo.toml to pin a dependency to a specific version.
Pin the version. Stop the drift.
The equals sign freezes time
Cargo uses Semantic Versioning (Semver) by default. When you write serde = "1.0", Cargo interprets that as ^1.0. The caret means "compatible with 1.0". Cargo will happily pull in 1.0.193, 1.0.194, or 1.1.0 if it fits the constraints. It tries to get you the latest compatible code.
The = operator overrides this behavior. serde = "=1.0.193" tells Cargo to fetch 1.0.193 and reject everything else. It rejects 1.0.194. It rejects 1.0.192. It rejects 1.1.0. Cargo only accepts the exact version you named.
This constraint applies to your direct request. It forces your crate to link against that specific version. It does not force the entire dependency graph to use that version, but it does restrict what Cargo can select for your crate. If another dependency needs a different version, Cargo must find a way to satisfy both constraints, or it fails.
The equals sign is a lock. Use it to freeze time, not to decorate your manifest.
Minimal example
You specify an exact version by placing = before the version number in Cargo.toml.
[dependencies]
# Pin to exactly 1.0.193.
# Cargo will reject any other version, including 1.0.194.
serde = "=1.0.193"
The = must touch the version number. Spaces around the operator are allowed but uncommon. The convention is no spaces: =1.0.193. This keeps the manifest compact and matches the style of most Rust projects.
How Cargo resolves the constraint
When you run cargo build, Cargo reads your Cargo.toml. It sees the = constraint. It queries the package index. It finds 1.0.193. It checks if any other dependency needs a different version.
If another crate needs serde = "1.0.190", Cargo can satisfy both. The constraint ^1.0.190 accepts 1.0.193. Cargo selects 1.0.193 for the whole graph. Your pin wins, but it doesn't break the other crate.
If another crate needs serde = "=1.0.192", Cargo fails. Your crate demands 1.0.193. The other crate demands 1.0.192. There is no version that satisfies both. Cargo emits a resolution error. You see output like failed to select a version for the requirement serde = "=1.0.193". You must update one of the pins or remove the constraint.
The = constraint is strict. It tells Cargo exactly what you want. If the rest of the graph can't accommodate that, the build stops. This is a feature. It prevents silent mismatches that could cause runtime errors.
Realistic scenario: Workarounds and reproducibility
Pinning serves two main purposes. The first is workarounds. A dependency releases a version with a bug. The maintainers haven't fixed it yet. You need to stay on the last known good version. You pin to that version. When the fix lands, you update the pin.
The second purpose is reproducibility. You're building a binary crate. You want every build to produce the exact same result. You pin every dependency. Now the build is deterministic. Even if the registry changes, your Cargo.toml forces the same versions.
[dependencies]
# Standard dependency. Accepts compatible updates.
# This is the default behavior for most crates.
tokio = { version = "1.35", features = ["full"] }
# Pinned dependency.
# Version 0.4.32 has a regression in timezone handling.
# Pin to 0.4.31 until the upstream fix is released.
chrono = "=0.4.31"
# Another pin for reproducibility.
# This binary requires exact versions for deterministic builds.
rand = "=0.8.5"
In this example, tokio uses the default caret behavior. Cargo will update it to newer 1.x versions. chrono and rand are pinned. Cargo will not update them. You control the updates manually.
Pinning is a bandage. Update the pin when the fix lands, or you'll bleed security holes.
Pinning versus locking
Cargo has two mechanisms for controlling versions. The = operator in Cargo.toml is a constraint. The Cargo.lock file is a record of the resolved versions. They work together, but they do different things.
Cargo.toml defines what versions are allowed. Cargo.lock records what Cargo actually chose. If you have serde = "1.0" and Cargo.lock has 1.0.193, cargo build uses 1.0.193. If you delete Cargo.lock and run cargo build, Cargo resolves again. It might pick 1.0.195. The lock file pins the resolution. The = in Cargo.toml pins the constraint.
For binary crates, the convention is to commit Cargo.lock to version control. This makes the build reproducible without = in the manifest. The lock file guarantees that every developer and CI run uses the same versions. The = is redundant here, but it serves as documentation of intent. It tells readers why you're on a specific version.
For library crates, the convention is to ignore Cargo.lock. Downstream users will have their own lock files. Your Cargo.toml is the source of truth. If you pin a dependency in a library, the = is critical. It enforces the constraint for all downstream users. Without the =, downstream users might get a different version that breaks your library.
Convention aside: Binary crates commit Cargo.lock. Library crates do not. If you're pinning in a binary, the lock file already handles exact versions. The = is optional documentation. If you're pinning in a library, the = is the only thing enforcing the constraint.
Pitfalls and conflicts
Pinning creates friction. If you pin serde = "=1.0.193", and you add a new dependency that requires serde = "1.0.200", Cargo might fail to resolve. The new dep needs a feature or fix only in 200, but you're stuck on 193. You have to update your pin. This is the "dependency hell" scenario. You're blocking updates that other crates need.
Pinning also blocks security patches. If 1.0.194 fixes a CVE, you don't get it automatically. You have to manually update the = line. If you forget, you stay vulnerable. You need a process to check for updates. Tools like Dependabot or Renovate can help. They open pull requests when pinned versions have updates. You review and merge them.
Transitive conflicts are common. You pin A = "=1.0". A depends on B = "2.0". You also depend on C. C depends on B = "=2.1". Cargo fails. A needs B in the 2.0 range. C needs exactly 2.1. If 2.1 is in the 2.0 range, Cargo might succeed. If not, it fails. You have to relax one of the pins or wait for a compatible update.
The = constraint is powerful. It gives you control. It also makes your crate harder to integrate with the rest of the ecosystem. Use it sparingly.
Default to caret. Pin only when you have a reason. Cargo resolves the rest.
Updating a pinned dependency
cargo update respects = constraints. It won't bump a pinned dependency. If you run cargo update, Cargo checks the constraints. It sees =1.0.193. It knows 1.0.194 is not allowed. It skips the update.
To update a pinned dependency, you must edit Cargo.toml. Change =1.0.193 to =1.0.194. Then run cargo update. Cargo sees the new constraint. It resolves to 1.0.194. It updates Cargo.lock.
You can also use cargo update -p serde. This updates only serde and its dependencies. It still respects the = constraint. If the constraint blocks the update, you still need to edit Cargo.toml.
Convention aside: When updating a pinned dependency, check the changelog. Verify that the bug you worked around is fixed. Verify that no new regressions exist. Update the pin. Run the tests. Commit the change. This keeps your pins accurate and your code safe.
Decision matrix
Use = when you need to pin a dependency to a specific version to work around a bug or regression in a newer release.
Use = when you are building a binary crate and require fully reproducible builds where every dependency version is explicit in the manifest.
Use ^ when you want to accept compatible updates, including bug fixes and new features, without breaking your code.
Use ~ when you want to allow patch updates but block minor version changes that might introduce new APIs.
Use >= when you have a hard minimum requirement but want to allow any newer version, though this is rare in practice.
Trust the borrow checker. It usually has a point. Trust Cargo's resolution too. It usually picks the right versions. Pin only when you have a reason.