The silent drift
Your project compiles perfectly on Tuesday. By Friday, a transitive dependency quietly releases a patch that changes a default behavior. Your tests pass locally, but the staging server throws a panic. You did not change a single line of code. The ecosystem moved underneath you. Keeping dependencies fresh is less about chasing new features and more about maintaining a predictable build environment.
The wish list versus the receipt
Rust splits dependency management into two files. Cargo.toml is your wish list. It says what you want and gives Cargo permission to pick a version within a range. Cargo.lock is the receipt. It records the exact version numbers, commit hashes, and feature flags that actually compiled your project.
Think of Cargo.toml as ordering a medium coffee with oat milk. Cargo.lock is the barista writing down the exact bean batch number, the oat milk carton opened at 8:15 AM, the precise ounce measurement, and the water temperature. The receipt guarantees you get the exact same drink tomorrow. When you want to update, you do not rewrite the wish list. You ask the barista to check if a newer batch of beans arrived.
Treat the lockfile as a contract. If it is missing, your builds are lying to you.
Auditing your dependency tree
The standard workflow starts with visibility. You need to know what is behind before you touch anything. The community standard for this is cargo-outdated, a third-party plugin that reads your lockfile and compares it against crates.io.
# Install the audit tool. It queries the registry and builds a dependency graph.
cargo install cargo-outdated
# Run the audit. It prints a table showing current versions alongside the latest available.
cargo outdated
# Apply the updates. Cargo rewrites Cargo.lock with the newest versions that match your constraints.
cargo update
The table output shows four columns. The first is the crate name. The second shows your current locked version. The third shows the latest version that satisfies your Cargo.toml constraints. The fourth shows the absolute latest version on crates.io, even if it breaks your constraints. This distinction matters. You usually want to follow the third column. The fourth column is a warning sign, not a target.
Run the tests before you commit the lockfile. Cargo guarantees the build, not the behavior.
How Cargo resolves versions
When you run cargo update, Cargo does not blindly download the newest release. It runs a constraint solver. It reads the version requirements in Cargo.toml, applies semantic versioning rules, and walks the entire dependency graph. Direct dependencies are the crates you listed yourself. Transitive dependencies are the crates those crates depend on. Cargo updates everything that fits inside your allowed ranges.
Semantic versioning in Rust follows a strict pattern. A requirement like ^1.2.3 means >=1.2.3 and <2.0.0. Cargo will happily bump 1.2.3 to 1.9.4, but it will stop at 2.0.0. A requirement like =1.2.3 pins the exact version. Cargo will never update it unless you change the file. A requirement like >=1.2.3, <2.0.0 is explicit but functionally identical to the caret syntax.
The solver works backward from your project. It finds the highest compatible version for each crate, then checks if those versions are compatible with each other. If crate-a requires crate-b ^1.0 and crate-c requires crate-b ^2.0, the solver fails. Cargo cannot satisfy both. You get a resolution error. This is why cargo update sometimes refuses to move forward. The dependency graph contains conflicting constraints.
Convention aside: the community treats Cargo.toml as the public contract and Cargo.lock as the private implementation detail. Binary applications must commit Cargo.lock to version control. Libraries usually do not. If you are building a CLI tool, a web server, or a game, the lockfile belongs in git. If you are publishing a crate for others to use, leave it out.
Handling breaking changes and aggressive updates
Sometimes the third column in cargo-outdated stops moving. Your constraint is ^1.0, but the maintainer released 2.0.0. The solver respects your boundary. You need to make a choice.
The safe path is manual. Open Cargo.toml. Change ^1.0 to ^2.0. Run cargo update. Run cargo test. Fix the compiler errors. Update your code to match the new API. Commit the changes. This keeps the upgrade traceable and isolates the breaking change to a single commit.
The dangerous path is the --aggressive flag. It tells Cargo to ignore every constraint in Cargo.toml and pull the absolute latest version for every crate, direct and transitive. It is useful for auditing long-dormant projects or preparing for a major rewrite. It is terrible for production. It trades predictability for novelty. One --aggressive run can pull in five major version bumps across twenty crates. Your code will not compile. Your tests will fail. You will spend days untangling breaking changes that arrived simultaneously.
Never trust --aggressive in production. It trades predictability for novelty.
CI/CD pipelines require a different rhythm. You cannot update dependencies on every commit. Upstream crates yank versions, introduce subtle bugs, or change default features. The standard practice is to pin Cargo.lock in version control and update it on a schedule. Weekly or monthly works for most teams. Open a pull request with the updated lockfile. Let the CI runner execute the full test suite. Merge only when green. If the pipeline fails, you know exactly which commit introduced the breakage. You can revert the lockfile and file an issue upstream.
Convention aside: cargo update is idempotent. Running it twice does nothing the second time. The community relies on this for CI stability. You can safely add cargo update to your build script without worrying about accidental double-updates.
Choosing your update strategy
Match the update command to the risk level. Small steps beat big leaps every time.
Use cargo update when you want to refresh all dependencies to the newest versions that still fit your Cargo.toml ranges. Use cargo update -p crate_name when a specific dependency has a patch release and you want to isolate the change. Use cargo update --aggressive when you are auditing a long-dormant project and need to force Cargo to ignore all version constraints. Edit Cargo.toml manually when the latest version you want falls outside your current semver range. Pin Cargo.lock in version control when you are shipping a binary application. Leave Cargo.lock out of version control when you are publishing a library.
Run the tests before you commit the lockfile. Cargo guarantees the build, not the behavior.