How to Handle Breaking Changes in Rust Dependencies

Pin dependency versions in Cargo.toml to prevent breaking changes and update manually when ready.

The build breaks, and you didn't touch the code

You are halfway through a feature branch. You run cargo build and the terminal explodes with errors. The compiler complains that parse_json no longer exists, or that Config is missing a field you rely on. You didn't change your code. You didn't change the dependency version. The error appeared out of nowhere.

This usually happens because a transitive dependency updated, or a CI job ran cargo update and pulled in a new minor version that exposed a latent incompatibility. Or perhaps you ran cargo update locally to get a security patch, and the update bumped a dependency to a version that changed its API in a way your code didn't expect. Rust handles dependency versions with a strict contract called semantic versioning. Understanding how Cargo interprets that contract stops the panic and gives you control over when and how breaking changes reach your code.

Semver is the contract

Rust follows semantic versioning, known as semver. Every crate version has three numbers: major.minor.patch. The numbers encode a promise about compatibility.

Patch changes fix bugs without touching the public API. Minor changes add new features or methods without removing or altering existing ones. Major changes are allowed to break the API. Functions can disappear. Struct fields can be removed. Return types can change.

Cargo uses these numbers to decide what updates are safe. When you specify a dependency, Cargo calculates a range of acceptable versions. By default, Cargo allows patch and minor updates. It blocks major updates. This means you get bug fixes and new features automatically, but your code never breaks due to a dependency upgrade unless you explicitly opt in.

The caret constraint and implicit ranges

When you write a version in Cargo.toml, you are usually writing a caret constraint. The caret symbol ^ is implicit.

[dependencies]
# This is shorthand for ^1.2.3
my-crate = "1.2.3"

The constraint ^1.2.3 expands to >=1.2.3, <2.0.0. Cargo will resolve this to any version from 1.2.3 up to, but not including, 2.0.0. If version 1.2.4 drops with a bug fix, Cargo picks it. If 1.3.0 drops with a new feature, Cargo picks it. If 2.0.0 drops with a breaking change, Cargo ignores it. Your code stays safe.

The implicit caret is a community convention. Most Cargo.toml files omit the ^ symbol because it is the default. Writing my-crate = "1.2.3" is idiomatic. Writing my-crate = "^1.2.3" is also valid but redundant. The convention is to drop the symbol for brevity.

If you are adding a dependency, use cargo add rather than editing Cargo.toml by hand. The tool applies the convention automatically and updates the lock file in one step.

# Adds my-crate with the latest compatible version and updates Cargo.lock
cargo add my-crate

Cargo.lock is the source of truth

The Cargo.toml file defines the constraints. The Cargo.lock file records the exact versions Cargo resolved. The lock file is generated automatically and should be committed to version control for applications.

When you run cargo build, Cargo checks the lock file. If the lock file exists, Cargo uses the exact versions recorded there. It ignores newer versions that might be available on crates.io. This guarantees that your build is reproducible. Every developer on the team and every CI runner gets the exact same dependency tree.

The lock file is your shield against accidental drift. Without it, cargo build could resolve to different versions on different machines, leading to "it works on my machine" bugs. Commit the lock file. Treat it as part of your source code.

Libraries do not commit Cargo.lock. A library's consumers need to resolve the library's dependencies alongside their own. If a library commits a lock file, it forces consumers to use specific versions, which causes conflicts. Applications commit the lock file. Libraries do not.

Updating safely

Dependencies accumulate over time. You will eventually need to update them to get security patches or new features. The command cargo update modifies the lock file. It respects the constraints in Cargo.toml.

# Updates all dependencies within their allowed ranges
cargo update

# Updates only one dependency
cargo update -p my-crate

Running cargo update without arguments updates every dependency in the tree. This can be risky. A broad update might pull in multiple new versions, making it hard to track which change broke your build. The safer pattern is to update one crate at a time. Use cargo update -p my-crate to bump a single dependency. Run your tests. If something breaks, you know exactly what caused it.

If you need to upgrade to a major version, cargo update won't help. The constraint in Cargo.toml blocks the major version. You must change the constraint first.

[dependencies]
# Bump the constraint to allow major version 2
my-crate = "2.0.0"

After changing the constraint, run cargo update -p my-crate. Cargo resolves the new version. You then compile and fix any API changes. This keeps the upgrade process controlled. You tackle breaking changes one crate at a time.

Transitive dependencies and the diamond problem

Your dependencies have dependencies. These are transitive dependencies. Cargo resolves the entire graph. Sometimes, two of your dependencies require different versions of the same transitive crate. This is the diamond problem.

Your App
  ├── reqwest 0.11
  │     └── hyper 0.14
  └── aws-sdk 1.0
        └── hyper 1.0

Cargo tries to unify versions. If reqwest needs hyper 0.14 and aws-sdk needs hyper 1.0, Cargo cannot unify them. The major versions differ. Cargo duplicates hyper. Your binary contains two copies of hyper. This is safe but increases binary size.

If both dependencies required hyper 0.14 and hyper 0.15, Cargo picks one version that satisfies both constraints. It picks the highest compatible version. If no single version satisfies all constraints, Cargo fails with a resolution error.

error: failed to select a version for `hyper`.
    ... required by package `reqwest v0.11.0`
    ... required by package `aws-sdk v1.0.0`

When this happens, you have a conflict. You need to update one of the top-level dependencies to relax its constraint, or use a patch to force a version. The cargo tree command helps you visualize the graph and find the conflict.

# Show the dependency tree for a specific crate
cargo tree -p reqwest

What counts as a breaking change

Not every API change is a breaking change. Semver defines the boundary precisely.

Removing a public function is breaking. Removing a public struct field is breaking. Changing the return type of a function is breaking. Adding a required generic parameter is breaking.

Adding a public function is not breaking. Adding a public struct field is not breaking. Adding a default generic parameter is not breaking. Fixing a bug is not breaking.

This distinction matters. A dependency can add features freely. Your code continues to compile. A dependency can remove features only in a major version bump. Cargo blocks the bump. Your code continues to compile. The contract holds.

Sometimes a change looks breaking but isn't. If a struct gains a new field, existing code that constructs the struct using named fields still compiles. The new field is optional for existing callers. If a trait gains a method with a default implementation, existing implementations of the trait still compile. The default implementation fills the gap.

Pitfalls and compiler signals

Pinning exact versions is a common mistake. Writing my-crate = "=1.2.3" forces Cargo to use only that version. You miss all patch updates, including security fixes. You also miss minor updates that add features. Use exact pins only when you are debugging a specific version issue or when reproducibility is more important than updates. Even then, exact pins are rare. The default caret constraint is almost always the right choice.

Another pitfall is ignoring the lock file in CI. If your CI pipeline runs cargo update before building, it might pull in a new version that breaks the build. CI should build exactly what is in the lock file. Run cargo build without cargo update. If you want to test against the latest versions, do it in a separate job or a periodic update workflow.

Compiler errors often point to the root cause. If you see E0432 (unresolved import), a function or module was removed. If you see E0599 (no method found), a method was renamed or removed. If you see E0277 (trait bound not satisfied), a type no longer implements a trait you rely on. These errors tell you exactly what broke. Fix the code to match the new API, or downgrade the dependency if the change is unacceptable.

Convention aside: When you fix a breaking change, update your code to use the new API. Don't just downgrade the dependency to silence the error. Downgrading delays the inevitable. The dependency will move forward. Your code should move with it.

Decision matrix

Use the default caret constraint my-crate = "1.2.3" for almost every application. It allows Cargo to pull in bug fixes and compatible features automatically while blocking breaking changes.

Use an exact constraint my-crate = "=1.2.3" only when you are debugging a specific version issue or building a container image where byte-for-byte reproducibility matters more than receiving security patches.

Use a range constraint my-crate = ">=1.2.3, <2.0.0" when you want to document the allowed range explicitly in Cargo.toml for team clarity.

Use cargo update -p my-crate to upgrade a single dependency without risking changes to the rest of your dependency tree.

Reach for [patch] in Cargo.toml when a transitive dependency has a bug that hasn't been fixed upstream, and you need to force a local fork or a newer version for the whole graph.

Trust the lock file. It remembers what worked. Commit it, and your builds stay stable.

Where to go next