The shopping list and the receipt
You're building a CLI tool that fetches weather data. You need to make an HTTP request, parse the JSON response, and handle async I/O. Writing a TCP stack, an HTTP parser, and a JSON deserializer from scratch is a fun weekend project, but it's not how you ship software. You need external crates. In Rust, adding those crates is a conversation with Cargo, the build system and package manager.
Cargo manages dependencies through two files. Cargo.toml is the manifest. It lists what you want. Cargo.lock is the lockfile. It records what you actually got. The manifest is your intent. The lockfile is the truth. Treat the manifest as your intent and the lockfile as the truth. The manifest says what you want; the lockfile says what you got.
How Cargo resolves dependencies
When you add a dependency, Cargo doesn't just download a file. It runs a resolution algorithm. Cargo reads your Cargo.toml, fetches metadata from crates.io, and builds a dependency graph. Every crate you add might depend on other crates. Those crates depend on others. Cargo walks this tree to find a set of versions where every requirement is satisfied.
If serde requires serde_derive version 1.0, and you have another crate that requires serde_derive version 1.0, Cargo picks version 1.0 once. If one crate requires log version 0.4 and another requires log version 0.3, Cargo fails. It cannot unify incompatible versions. You'll see a resolution error telling you which crates conflict.
Once resolution succeeds, Cargo writes the exact versions to Cargo.lock. This file pins every transitive dependency. If you run cargo build a year later, Cargo reads the lockfile and fetches the exact same versions, even if newer releases appeared on crates.io. This guarantees reproducible builds. Your code compiles the same way on your laptop, your teammate's machine, and the CI server.
Adding a crate with cargo add
The standard way to add a dependency is the cargo add command. It edits Cargo.toml for you, triggers resolution, and updates Cargo.lock. It's safer than manual editing because it catches resolution errors immediately.
# Adds serde to the dependencies section.
# The --features flag enables optional functionality.
# Cargo resolves versions and updates Cargo.lock.
cargo add serde --features derive
After this command, Cargo.toml contains a line for serde. The version is written in a format Cargo understands. The lockfile now includes serde and all its transitive dependencies. You can start using the crate in your code.
// src/main.rs
/// Demonstrates using serde to deserialize a string.
fn main() {
// Serde provides the Deserialize trait.
// The derive feature generates the implementation.
#[derive(serde::Deserialize)]
struct Weather {
temperature: f32,
}
let json = r#"{"temperature": 72.5}"#;
// Parse the JSON string into the struct.
let weather: Weather = serde_json::from_str(json).unwrap();
println!("Temp: {}", weather.temperature);
}
If you try to use serde without adding it, the compiler rejects the code with E0432 (use of undeclared crate or module). If you add serde but forget the derive feature, you'll get E0277 (trait bound not satisfied) when you try to use #[derive(Deserialize)]. The error points to the missing feature. The fix is to add the feature flag.
Version constraints and SemVer
Rust uses Semantic Versioning, or SemVer. Versions follow the MAJOR.MINOR.PATCH pattern. Cargo interprets version constraints based on SemVer rules.
When you write tokio = "1.35", Cargo treats this as >=1.35.0, <2.0.0. It will pick the latest 1.x version available. If tokio releases 1.36, Cargo upgrades to it. If tokio releases 2.0, Cargo stops at 1.x because the major version bump signals breaking changes.
You can be more specific. tokio = "=1.35.0" pins the exact version. This is rarely necessary. It makes updates harder because you have to edit the manifest manually to bump the version. The default behavior is usually best. It allows patch and minor updates automatically while protecting you from breaking changes.
Rust trusts SemVer. If a crate bumps to 2.0, it promises breaking changes. If it stays in 1.x, it promises safety. Write constraints that match your risk tolerance.
Features and optional functionality
Many crates split their functionality into features. Features are optional capabilities you can enable. They keep compile times down and binary size small. You only pay for what you use.
serde is the classic example. The core crate provides the traits. The derive feature adds the macros that generate implementations. The std feature enables standard library support. If you don't need derive, you can omit it. If you're building for no_std, you can disable std.
# Cargo.toml snippet
[dependencies]
# Enable only the features you actually use.
# This reduces compile time and binary size.
serde = { version = "1.0", features = ["derive", "std"] }
Features are additive. Enabling a feature never breaks your code; it only adds capabilities. If you enable a feature that requires a new dependency, Cargo pulls that dependency in. The resolution algorithm handles the rest.
Enable only the features you use. Every extra feature adds compile time and binary size. Keep your dependency surface small.
Realistic dependency configurations
Real projects often need more than simple version strings. You might work with a local library, a Git repository, or a crate with complex feature requirements.
# Cargo.toml
[dependencies]
# Simple version constraint. Cargo picks the latest 1.x.
tokio = "1.35"
# Explicit features. Derive macros are optional in serde.
serde = { version = "1.0", features = ["derive"] }
# Local path dependency. Useful for monorepos or testing.
# Cargo reads the crate from the filesystem.
my-utils = { path = "../my-utils" }
# Git dependency. Useful for unreleased code.
# Specify the branch or tag to ensure stability.
my-experimental-lib = { git = "https://github.com/example/repo", branch = "main" }
Path dependencies are common in monorepos. You have multiple crates in one repository. One crate depends on another. You use path to link them. Cargo treats path dependencies like regular crates. It compiles them and links them. Changes to the path crate are picked up immediately.
Git dependencies let you use code that hasn't been published yet. You specify the repository URL and a branch, tag, or commit. Cargo clones the repo and uses that version. Git dependencies can be slower to resolve because Cargo might need to fetch the repository. Use them sparingly. Prefer published crates when possible.
Updating and removing dependencies
Dependencies drift over time. New versions appear with bug fixes and improvements. You need a way to update.
Use cargo update to bump dependencies to the latest versions that satisfy your constraints. If you have serde = "1.0", cargo update will move serde to the latest 1.x version. It updates Cargo.lock but leaves Cargo.toml unchanged.
# Updates all dependencies to latest compatible versions.
cargo update
# Updates only serde. Other crates stay pinned.
cargo update -p serde
If you need a specific version, you can force it. cargo update -p serde --precise 1.0.194 pins serde to that exact version in the lockfile. This is useful for debugging or rolling back a bad update.
To remove a dependency, use cargo remove. It deletes the line from Cargo.toml and cleans up Cargo.lock.
# Removes serde from the manifest and lockfile.
cargo remove serde
If you remove a dependency manually from Cargo.toml, run cargo update to clean the lockfile. Otherwise, the lockfile retains the old version, which can cause confusion.
Dev-dependencies and testing
Not all dependencies are needed for the final binary. Testing frameworks, benchmarking tools, and mock libraries are only used during development. These go in the [dev-dependencies] section.
# Adds tempfile as a dev-dependency.
# It is available in tests but not in the final binary.
cargo add tempfile --dev
Dev-dependencies are compiled only when you run cargo test or cargo bench. They don't affect the release build. This keeps your binary small and reduces compile times for production.
Keep dev-dependencies separate. Your users don't need your test harness.
Pitfalls and compiler errors
Adding dependencies is straightforward, but a few traps exist.
Forgetting to commit Cargo.lock is the most common mistake. If you share a project without the lockfile, other developers might resolve different versions. Your code might work on your machine but fail on theirs because a transitive dependency changed. Always commit Cargo.lock for binary crates. For library crates, the convention is debated, but committing it is safer for reproducibility.
Feature conflicts can cause subtle errors. If you enable a feature that conflicts with another crate's requirements, resolution fails. The error message lists the conflicting crates and features. The fix is usually to adjust features or update one of the crates.
If you see E0432 after adding a crate, check the manifest. The crate might not be listed, or the name might be wrong. If you see E0277 related to a trait you expected, check the features. The trait might be behind a feature flag.
Commit the lockfile. Reproducibility isn't optional.
When to use each approach
Use cargo add when you want a quick, safe way to add a crate and let Cargo handle version resolution and lockfile updates. Use cargo add --dev when the dependency is only needed for tests or benchmarks. Edit Cargo.toml manually when you need complex configurations like multiple sources, detailed patch sections, or you are setting up a workspace root. Use cargo update when you want to bump dependencies to newer versions that still satisfy your constraints. Use cargo update -p when you need to update a specific crate without touching others. Use cargo remove when a dependency is no longer needed and you want to clean up the manifest and lockfile. Reach for path dependencies when you are developing a local library alongside your application and need to test changes without publishing. Reach for Git dependencies only when the code is not available on crates.io and you cannot wait for a release.