Editing Cargo.toml without editing Cargo.toml
For the first year or so of any Rust learner's life, the routine for adding a dependency goes like this. Find the crate on crates.io. Note the version, e.g. serde = "1.0.197". Open Cargo.toml. Type the new line in the right section. Save. Run cargo build and hope you got the version string right.
You can do this for years. It works. It's also tedious, easy to typo, and a magnet for slightly-out-of-date version strings copy-pasted from old blog posts. The fix is a small set of subcommands that read crates.io for you, write the right line into your Cargo.toml, and go away. The classic name for these is cargo-edit, an external crate that introduced cargo add, cargo rm, and cargo upgrade. Two of them are now built into Cargo itself; the third still lives in cargo-edit. The rest of this article walks through each one, when to use it, and what the modern story actually looks like.
What ships with Cargo today
As of Rust 1.62 (mid-2022), cargo add is built into Cargo. You don't need to install anything. Try it on any project:
# Add the latest version of serde, with the `derive` feature enabled.
cargo add serde --features derive
# Add tokio at a specific version, with several features.
cargo add tokio@1 --features full
# Add a dev-only dependency (lands in [dev-dependencies]).
cargo add --dev proptest
# Add a build-time dependency (lands in [build-dependencies]).
cargo add --build cc
Under the hood, cargo add resolves the latest compatible version from crates.io (or your configured registry), figures out the right TOML section based on flags, and writes a new line. It also rewrites your [features] table if you ask for features the crate exposes.
A few flags that come up often:
--git <url>to add a git dependency.--path ../some-local-crateto add a path dependency, useful in workspaces.--rename my_serdeto give it a local name in case of a clash.--no-default-featuresif you want to opt out of the crate's defaults.--optionalto make it an optional dep, used to gate features.
There's also cargo remove (sometimes spelled cargo rm in older docs and shipped that way in cargo-edit):
# Remove a dependency cleanly. Edits both [dependencies] and any feature
# table that referenced it.
cargo remove serde
That's the built-in pair. Both work in workspaces. Both are safe to run repeatedly.
What cargo-edit still gives you: cargo upgrade
The one piece that hasn't (as of writing) been pulled into Cargo proper is cargo upgrade. For this you still need the external crate:
cargo install cargo-edit
Then:
# Bump every dependency in Cargo.toml to its latest compatible version.
# "Compatible" here means latest that satisfies your existing version requirements.
cargo upgrade
# Bump even past your version requirement, e.g. from 1.x to 2.x.
# This rewrites the version string in Cargo.toml. Read the changelogs first.
cargo upgrade --incompatible
# Just one crate, with a specific target version.
cargo upgrade serde@1.0.200
# Show what would change without writing.
cargo upgrade --dry-run
The distinction between cargo upgrade and cargo update confuses newcomers, so it's worth being explicit. cargo update only modifies Cargo.lock, picking new versions that satisfy the version ranges already in Cargo.toml. cargo upgrade modifies Cargo.toml itself, rewriting the ranges. After cargo upgrade, you usually run cargo update (which cargo upgrade does for you implicitly when it succeeds) to refresh the lockfile, and then cargo build to actually pull and compile the new versions.
A quick analogy: Cargo.toml is the shopping list (what you allow), Cargo.lock is the receipt (what you actually got). cargo update lets you redo the shopping with the same list. cargo upgrade rewrites the list, then redoes the shopping.
A walkthrough: adding tokio and serde
Say you're starting a small JSON-over-TCP service. From a fresh cargo new myservice you'd typically do:
# Add Tokio with the full feature set so we get TcpListener and friends.
cargo add tokio@1 --features full
# Serde plus the derive macro so we can #[derive(Serialize, Deserialize)] on structs.
cargo add serde@1 --features derive
# JSON support, separate crate.
cargo add serde_json
# A test-only assertion helper, only pulled in by `cargo test`.
cargo add --dev pretty_assertions
After those four commands, your Cargo.toml will look approximately like:
[package]
name = "myservice"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[dev-dependencies]
pretty_assertions = "1"
The nice part is you didn't open Cargo.toml at all. You also didn't have to remember the latest version of any crate.
What the version string actually means
When cargo add tokio writes tokio = "1.40" (or similar), the leading 1.40 is shorthand for ^1.40, which means "any version >= 1.40.0 and < 2.0.0." That's Cargo's default semver-style range. You usually want this, because most crate authors follow semver: minor and patch updates won't break your code, major versions might.
If you specifically pin to a version, say tokio@=1.40.0, you'll get exactly that. That's rare and almost always the wrong call for a library, because it forces every downstream user to also pin to that exact version.
Common pitfalls
You ran cargo add from outside a Cargo project. You'll see something like:
error: could not find `Cargo.toml` in `/home/me` or any parent directory
The fix is to cd into a project root.
You tried cargo upgrade and got error: no such command: upgrade. That's because it's not built-in. Run cargo install cargo-edit first.
You ran cargo upgrade on a tightly version-pinned project (e.g. you wrote serde = "=1.0.197") and nothing happened. The = operator means "exactly this version," so there's no range to upgrade within. Drop the = if you want non-pinned semver behavior.
You ran cargo upgrade --incompatible and your build broke. That's the expected risk of crossing a major version boundary. Read the changelog for each upgraded crate, fix the API breaks, and run the tests. Doing it one crate at a time is usually saner than upgrading everything at once.
You ran cargo add and it picked an older version than you expected. Cargo respects your Rust toolchain's MSRV (minimum supported Rust version) when selecting versions if the crate declares rust-version. If your toolchain is older, you'll get the latest version that compiles for your toolchain. Update your toolchain (or the project's rust-version field) to fix this.
You added a feature with --features but your code still doesn't compile. Some crates have features that gate further dependencies which themselves have features. Read the crate's docs to confirm you've got the right feature name, and run cargo build -v to see what's actually being compiled.
You've added a workspace member. cargo add and cargo remove work inside a member crate, but if you want to manage workspace-level dependencies ([workspace.dependencies]), the syntax is slightly different and not all flags are supported. Sometimes opening Cargo.toml is still faster.
When to reach for these vs editing by hand
For day-to-day adding and removing of dependencies, cargo add and cargo remove are simply faster and less error-prone. Use them.
For first-time exploration where you're unsure of the version or features, cargo add is also useful because it'll print the version and feature list it picked.
For mechanical bumps across many crates, cargo upgrade is great. Run it monthly, check what changed, run tests.
For surgical edits (adding a [patch] section, renaming a path dep, configuring default-features = false on multiple crates at once, restructuring a workspace), open Cargo.toml in your editor. The CLI tools work for the common case; complex configuration is still cleaner by hand.
For reproducible CI builds, don't run cargo upgrade in CI. Run it locally, commit the new Cargo.toml and Cargo.lock, push. CI's job is to verify the lockfile, not change it.
Where to go next
Most Useful Cargo Plugins Every Rust Developer Should Know