Adding Dependencies with Cargo
When a Rust program needs functionality beyond what the standard library provides — parsing JSON, generating random numbers, making HTTP requests — you reach for a crate: a published package of Rust code. Cargo, Rust’s built-in build tool and package manager, is what lets you declare which crates your project needs, fetch the right versions automatically, and rebuild reproducibly on any machine. Understanding how Cargo manages dependencies — through Cargo.toml, semantic versioning, and Cargo.lock — is essential to working in the Rust ecosystem, since almost no real-world Rust project relies on the standard library alone.
Overview: How Cargo Manages Dependencies
Every Cargo project (created with cargo new) has a manifest file called Cargo.toml at its root. This file describes the package itself — its name, version, and edition — and lists the external crates it depends on under a [dependencies] table. When you run cargo build or cargo run, Cargo reads this manifest, works out exactly which versions of each dependency (and each dependency’s own dependencies, forming a full dependency graph) satisfy every version requirement you wrote, downloads the source code of those crates from the public registry at crates.io — or from a git repository or local path, if you specified one instead — compiles them, and links everything together into your final binary or library.
Two files sit at the center of this system, and they serve different purposes. Cargo.toml is what you edit by hand (or with cargo add): it expresses the range of versions you are willing to accept, written using semantic versioning. Cargo.lock, generated automatically, records the exact version of every crate that was actually resolved the first time the project built successfully. Once a lock file exists, later builds reuse those exact versions instead of re-resolving — so a build done today uses the same dependency versions as one done six months ago, even if newer versions have since been published. This is what makes Rust builds reproducible: your teammate, your CI server, and your future self all compile against identical crate versions unless someone deliberately runs cargo update.
Version requirements in Cargo.toml default to caret requirements. Writing rand = "0.8.5" is shorthand for ^0.8.5, meaning "any version compatible with 0.8.5 under semantic versioning" — in practice >=0.8.5, <0.9.0, since Cargo treats the first nonzero component before a crate reaches 1.0 as the compatibility boundary. This lets Cargo automatically pick up bug-fix and additive releases without you touching the manifest, while refusing to jump to a version that might contain breaking changes. Once a dependency is compiled, the intermediate build artifacts live in your project’s target/ directory, and the downloaded source sits in a global cache under ~/.cargo/registry, shared across every project on your machine — adding the same crate to a second project does not re-download it.
Syntax
Dependencies are declared inside one or more tables in Cargo.toml. The most common forms:
[dependencies]
rand = "0.8.5"
serde = { version = "1.0", features = ["derive"] }
local_util = { path = "../local_util" }
regex = { git = "https://github.com/rust-lang/regex", branch = "master" }
[dev-dependencies]
assert_cmd = "2.0"
[build-dependencies]
cc = "1.0"
| Form | Meaning |
|---|---|
name = "1.2.3" |
Shorthand for a caret requirement, fetched from crates.io. |
{ version = "1.2.3", features = [...] } |
Table form; also lets you enable optional crate features. |
{ path = "..." } |
A dependency on another local crate by filesystem path — no download, no version resolution against crates.io. |
{ git = "...", branch/tag/rev = "..." } |
Pulled directly from a git repository instead of a registry. |
[dev-dependencies] |
Compiled only for cargo test, cargo bench, and examples — never included in a release build of your library or binary. |
[build-dependencies] |
Used only by a build.rs build script, compiled separately from your crate. |
In practice you rarely hand-type these entries. The cargo add subcommand looks up the latest matching version on crates.io and writes the entry for you:
cargo add rand
cargo add serde --features derive
cargo add assert_cmd --dev
Examples
Example 1: Check the standard library first
Not every problem needs an external crate. Rust’s standard library already ships collections, string handling, and file I/O — reaching for a dependency when std already solves the problem adds compile time and maintenance burden for no benefit. This program uses only std::collections::HashMap, so its Cargo.toml needs no [dependencies] table at all:
use std::collections::HashMap;
fn main() {
let mut inventory: HashMap<String, u32> = HashMap::new();
inventory.insert(String::from("apples"), 50);
inventory.insert(String::from("bananas"), 20);
if let Some(count) = inventory.get("apples") {
println!("We have {} apples in stock.", count);
}
println!("Total item types: {}", inventory.len());
}
Output:
We have 50 apples in stock.
Total item types: 2
No [dependencies] entry was needed because HashMap, like Vec, String, and Option, ships as part of the standard library and is always available via use std::....
Example 2: Adding a random-number crate
Generating random numbers isn’t something std provides — deliberately, to keep the standard library small and avoid picking one RNG algorithm as canonical — so this is a genuine case for a dependency. Running cargo add rand inside a project adds this to Cargo.toml:
[package]
name = "guess_game"
version = "0.1.0"
edition = "2021"
[dependencies]
rand = "0.8.5"
With that entry in place, the crate’s public API becomes available under the rand:: path anywhere in the project:
use rand::Rng;
fn main() {
let mut rng = rand::thread_rng();
let secret = rng.gen_range(1..=100);
println!("The secret number is: {}", secret);
}
Output (the exact number changes every run, since it is random):
The secret number is: 47
The first time this project builds, Cargo resolves "0.8.5" against crates.io, downloads the source, compiles it, and records the exact resolved version — for example 0.8.5, plus its own transitive dependencies like rand_core — in Cargo.lock. Every later build reuses that resolution instantly, without contacting crates.io again, until someone runs cargo update.
Example 3: Enabling optional features
Many crates split optional functionality behind features to keep the default build small. serde, the most widely used serialization crate, requires you to explicitly opt in to its derive feature before you can write #[derive(Serialize)] on your own types:
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
use serde::Serialize;
#[derive(Serialize)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let p = Point { x: 3, y: 7 };
let json = serde_json::to_string(&p).unwrap();
println!("{}", json);
}
Output:
{"x":3,"y":7}
Without the features = ["derive"] line, the #[derive(Serialize)] attribute fails to compile, because the derive macro itself lives behind that feature flag and is not compiled in by default.
How Cargo Resolves and Builds Dependencies Step by Step
When you run cargo build, Cargo performs the same sequence of steps every time:
- Parse
Cargo.tomland collect every version requirement, including those pulled in transitively by your dependencies’ own manifests. - If a
Cargo.lockalready exists and still satisfies every requirement, reuse the exact versions it records. Otherwise, run the version resolver: it consults the crates.io index — a repository of package metadata mirrored locally — and picks the newest version of each crate that satisfies every constraint at once. - Write (or update)
Cargo.lockwith the resolved versions, so the next build can skip resolution entirely. - Download the source archive of any crate not already present in the local cache under
~/.cargo/registry/src. - Compile each dependency crate, in dependency order, into an intermediate library artifact, caching the result under
target/debug/deps(ortarget/release/depsforcargo build --release) so unrelated future builds do not recompile it. - Compile your own crate against those artifacts and link everything into the final binary or library.
Because steps 4 and 5 are cached per exact version and set of enabled features, adding a new dependency to an existing project is usually fast after the very first build — only the newly added crate, and anything that depends on it, needs to be built.
Common Mistakes
Mistake 1: Using an unconstrained wildcard version
It’s tempting to write a dependency without thinking about its version at all:
[dependencies]
rand = "*"
This tells Cargo to accept literally any published version, including future major releases with breaking API changes. It defeats the purpose of semantic versioning and makes your build unpredictable across machines and time — crates.io actually refuses to let you publish a crate that depends on another crate this way. Pin a real requirement instead, which is exactly what cargo add writes automatically:
[dependencies]
rand = "0.8.5"
Mistake 2: Forgetting to enable a required feature
Adding a crate without the feature its API needs produces a confusing compiler error rather than a missing-dependency error:
[dependencies]
serde = "1.0"
use serde::Serialize;
#[derive(Serialize)]
struct Point {
x: i32,
y: i32,
}
fn main() {}
error[E0433]: failed to resolve: could not find `Serialize` in `serde`
--> src/main.rs:3:10
|
3 | #[derive(Serialize)]
| ^^^^^^^^^ could not find `Serialize` in `serde`
The fix is to enable the feature that provides the derive macro, not to search for a different crate:
[dependencies]
serde = { version = "1.0", features = ["derive"] }
Mistake 3: Putting test-only crates under [dependencies]
A crate that’s only ever used inside #[cfg(test)] modules or integration tests — such as an assertion helper — doesn’t belong in [dependencies]:
[dependencies]
assert_cmd = "2.0"
Placed here, assert_cmd is compiled into every ordinary build of your binary, inflating compile times and — for a published library — forcing every downstream user to pull it in too, even though it is never used outside your own test suite. Move it to [dev-dependencies], which Cargo compiles only for cargo test, cargo bench, and examples:
[dev-dependencies]
assert_cmd = "2.0"
Best Practices
- Prefer
cargo add <crate>over hand-editingCargo.toml— it fetches the current version and writes a correct entry, reducing typos and stale versions. - Commit
Cargo.lockfor binary projects (applications), so every build — yours, a teammate’s, or CI — uses identical dependency versions. For a library crate meant to be reused by others, the common convention is to leaveCargo.lockout of version control, since the final application depending on your library controls resolution. - Run
cargo updatedeliberately, as its own commit, rather than letting dependency versions drift silently — that way a broken upgrade is easy to bisect and revert. - Use
cargo treeto inspect the full resolved dependency graph, including transitive dependencies, before adding a crate with a large footprint. - Keep dependencies in
[dev-dependencies]or[build-dependencies]when that is genuinely their only use, to keep your shipped binary lean. - Enable only the crate features you actually need — unnecessary features pull in extra transitive dependencies and increase compile time.
- Before adding a crate, check its documentation on docs.rs and its maintenance activity on crates.io; prefer well-maintained, widely used crates for anything security- or correctness-sensitive.
Practice Exercises
- Create a new binary project with
cargo new guess_game, runcargo add randinside it, and write a program that prints a random integer between 1 and 6 (inclusive), simulating a die roll. - Add
serdeandserde_jsonto a project, remembering thederivefeature, define a small struct representing a person (name and age), and print it as a JSON string. Expected shape of the output:{"name":"Ava","age":29}. - Run
cargo treeon any project that has at least one dependency and read through the output — identify which crates are direct dependencies (listed right under your own crate) versus transitive ones (indented further, pulled in by another crate).
Summary
Cargo.tomldeclares which crates your project depends on and the range of versions you accept;Cargo.lockrecords the exact versions Cargo actually resolved, keeping builds reproducible.- Version requirements default to caret requirements —
"1.2.3"means^1.2.3— which allow compatible upgrades but block breaking ones. cargo add <crate>is the preferred way to add a dependency; it writes the entry for you and supports flags like--featuresand--dev.- Dependencies can come from crates.io (the default), a git repository, or a local path, using the table form of a dependency entry.
[dev-dependencies]and[build-dependencies]keep test-only and build-script-only crates out of your shipped binary.- Crate features let you opt in to optional functionality, such as
serde‘sderivemacros, without paying the compile-time cost when you don’t need it. - Before reaching for a dependency, check whether the standard library already solves the problem.
