You need a crate. You open Cargo.toml.
You are building a command-line tool that reads a configuration file. Rust's standard library gives you file I/O, but it doesn't parse TOML, JSON, or YAML. You go to crates.io, search for a parser, and find toml. The documentation shows you a line of code: toml = "0.8". You paste that into your project, run cargo build, and suddenly your code can parse configuration files.
That magic line lives in Cargo.toml. This file is the manifest for your crate. It tells Cargo what your project is called, what version it is, and what external code it needs to compile. Adding a dependency is the act of declaring that need. Cargo takes that declaration, finds the matching code on the registry, downloads it, compiles it, and links it to your binary.
Think of Cargo.toml as a smart shopping list. You don't just write "milk". You write "milk, any brand, as long as it's fresh and hasn't expired". Cargo goes to the store, checks the shelves, verifies the expiration date, ensures the milk works with the cereal you already bought, and brings it back. If the store only has expired milk, Cargo refuses to buy it and tells you why.
The fast way: cargo add
Editing Cargo.toml by hand works, but it invites typos and syntax errors. The community standard is to use cargo add. This command updates the manifest, downloads the crate, and refreshes the lock file in one step. It handles version resolution immediately, so you see errors before you even write code.
// Run this in your terminal, not in Rust code.
// cargo add serde
//
// This command:
// 1. Fetches the latest compatible version of serde from crates.io.
// 2. Adds `serde = "1.0"` to [dependencies] in Cargo.toml.
// 3. Downloads serde and its dependencies.
// 4. Updates Cargo.lock with the exact versions resolved.
// 5. Runs cargo fetch to ensure all artifacts are cached.
cargo add also supports flags for features, dev dependencies, and renaming. It keeps your Cargo.toml sorted alphabetically, which is a community convention that makes large manifests readable. Manual editing is reserved for copying complex blocks or performing bulk changes. For day-to-day work, the command line is the tool.
Convention aside: cargo add is preferred over manual editing because it validates the crate name and version against the registry instantly. If you typo the crate name, cargo add fails immediately. Manual editing waits until cargo build to catch the mistake.
Version constraints and the hidden caret
When you write serde = "1.0", you are not asking for version 1.0 exactly. You are asking for a range. Cargo uses semantic versioning, and the default constraint is the "caret" requirement. The caret is implied, so you rarely type it.
The constraint 1.0 means >=1.0.0, <2.0.0. Cargo will pick the latest version that satisfies this range. If 1.0.195 exists, Cargo picks that. If 2.0.0 is released, Cargo stops at 1.99.99. This allows your project to receive bug fixes and new features automatically, while blocking breaking changes.
You can drop trailing zeros. 1.0, 1.0.0, and 1 are identical constraints. They all mean >=1.0.0, <2.0.0. This is a convenience feature in Cargo's parser. It reduces noise in the manifest.
[dependencies]
# Caret constraint: >=1.0.0, <2.0.0.
# Trailing zeros are optional. These three lines are equivalent.
serde = "1"
serde = "1.0"
serde = "1.0.0"
# Explicit caret: same as above, but verbose.
serde = "^1.0.0"
# Exact version: only this specific release.
# Rarely used. Blocks updates and causes conflicts.
tokio = "=1.35.0"
# Tilde constraint: >=1.2.0, <1.3.0.
# Allows patch updates, blocks minor updates.
# Rarely needed. Caret is usually sufficient.
reqwest = "~1.2.0"
Exact versions (=1.35.0) lock you to a single release. This is almost never what you want. It prevents you from getting security fixes. It creates diamond dependency conflicts when other crates ask for a newer version. Use exact versions only when you are debugging a specific regression and need to reproduce a state.
Trust the caret. It keeps your code moving forward without breaking.
Cargo.lock: The receipt, not the list
Cargo.toml contains ranges. Cargo.lock contains exact versions. When you run cargo build, Cargo resolves the ranges in the manifest and writes the results to Cargo.lock. This file pins every dependency to a specific version, including dependencies of dependencies.
The lock file guarantees deterministic builds. If you build the project today, and your teammate builds it tomorrow, you both get the exact same binary, assuming the lock file is shared. The lock file is the receipt from the store. It proves exactly what was bought.
# Snippet from Cargo.lock
# This file is generated by cargo. Do not edit it manually.
[[package]]
name = "serde"
version = "1.0.195"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "..."
dependencies = [
"serde_derive",
]
You should commit Cargo.lock to version control for binary crates. This ensures everyone builds the same artifact. For library crates, the convention is more flexible. Some teams commit the lock file to pin the build environment. Others skip it to allow downstream users to resolve dependencies against their own constraints. The modern trend is to commit it everywhere for reproducibility.
If you update a dependency in Cargo.toml, the lock file doesn't change until you run cargo update or cargo build. cargo update refreshes the lock file by re-resolving the ranges. It picks the latest versions that satisfy the constraints. This is how you pull in new releases.
Commit the lock file. Your future self will thank you when the build works.
Features: Turning on optional power
Many crates offer optional functionality through features. A feature is a flag that enables or disables parts of the crate. Crates use features to reduce compile time and binary size. If you don't need JSON support, you don't pay the cost of compiling the JSON parser.
You enable features in Cargo.toml or via cargo add. The syntax adds a features key to the dependency entry.
[dependencies]
# Enable the "json" and "blocking" features.
# This tells reqwest to compile JSON support and synchronous APIs.
reqwest = { version = "0.11", features = ["json", "blocking"] }
# Enable a default feature that was disabled.
# Some crates disable heavy features by default.
serde = { version = "1.0", features = ["derive"] }
Features are additive and unified across the dependency graph. If reqwest enables the json feature, and reqwest depends on serde with the derive feature, your project gets serde with derive enabled, even if you didn't ask for it explicitly. Cargo merges all feature requests for a given crate. This prevents duplicate code and ensures consistency.
Ah-ha moment: Feature unification means you can't have two versions of a crate with different features in the same build. Cargo picks one version and enables the union of all requested features. If A needs B with feature x, and C needs B with feature y, you get B with both x and y. This is why enabling a feature in one dependency can change the behavior of another.
Features are the difference between a Swiss Army knife and a kitchen sink. Pick what you need.
Dev and build dependencies
Not all dependencies belong in the main binary. Test frameworks, benchmarking tools, and build scripts have different lifecycles. Cargo provides separate sections for these cases.
[dev-dependencies] are compiled only when you run cargo test or cargo bench. They are excluded from production builds. This keeps your binary small and reduces compile time for releases.
[build-dependencies] are compiled only when you run cargo build to execute a build.rs script. They are not available to your library or binary code. They are used for code generation, compiling C libraries, or other pre-build tasks.
[dependencies]
# Available to your library and binary code.
serde = "1.0"
[dev-dependencies]
# Available only in tests and benchmarks.
# criterion is a benchmarking framework.
criterion = "0.5"
[build-dependencies]
# Available only in build.rs.
# cc is a helper for compiling C code.
cc = "1.0"
Using the correct section matters. If you put a test framework in [dependencies], it bloats your production binary. If you put a runtime dependency in [dev-dependencies], your code compiles during tests but fails when you build the release binary.
Convention aside: cargo add supports --dev and --build flags. cargo add criterion --dev places the crate in the correct section automatically. This prevents misplacement errors.
Pitfalls and compiler errors
Adding dependencies can go wrong. The compiler will tell you, but the errors can be cryptic if you don't know what to look for.
E0432 (use of undeclared type) often appears when you add a dependency but forget to import it. The crate is downloaded, but your code doesn't reference it. You need use crate_name::Type; to bring the item into scope.
E0277 (trait bound not satisfied) frequently indicates a missing feature. You might try to use serde::Serialize on a struct, but the compiler complains that the trait is not implemented. This happens when you depend on serde without the derive feature. The #[derive(Serialize)] macro is behind a feature flag. Enable the feature, and the error vanishes.
use serde::Serialize;
// This fails with E0277 if the "derive" feature is not enabled.
// The compiler cannot find the Serialize implementation.
#[derive(Serialize)]
struct Config {
name: String,
}
Version conflicts occur when two dependencies require incompatible versions of a third crate. Cargo tries to resolve this by finding a single version that satisfies all constraints. If A needs B ^1.0 and C needs B ^1.2, Cargo picks B 1.2. If A needs B ^1.0 and C needs B ^2.0, Cargo fails. There is no version that satisfies both. You get a resolution error listing the conflicting requirements.
When resolution fails, you can try cargo update to refresh the lock file. Sometimes a newer version of a dependency relaxes its constraints. If that doesn't work, you may need to update your own dependencies to align the ranges.
Don't fight the compiler here. Check your features and version ranges first.
Decision matrix
Use cargo add when you are adding a new dependency to your project. It automates the manifest update, downloads the crate, and validates the version instantly.
Use manual editing when you are copying a complex dependency block from documentation or performing bulk changes across multiple crates.
Use [dependencies] when the crate is required for your library or binary to function in production.
Use [dev-dependencies] when the crate is only needed for tests, benchmarks, or examples. This keeps your production binary lean.
Use [build-dependencies] when the crate is only needed in build.rs for code generation or compiling external code.
Use features when you need optional functionality that the crate provides behind a flag. Enable only the features you use to minimize compile time.
Use exact version constraints only when you are debugging a specific regression and need to pin a dependency to a known state. Avoid them in normal development.