Cargo and the Crates Ecosystem
Cargo.toml dependencies, semantic versioning, Cargo.lock, workspaces, and crates.io.
Cargo.toml anatomy
Every Cargo project (a "crate") is described by a Cargo.toml file at its root — the equivalent of package.json in Node or a .csproj in .NET:
[package]
name = "hello_rust"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
rand = "0.8"
[dev-dependencies]
criterion = "0.5" # only needed for tests/benchmarks, never compiled into the release binary
[dependencies] are compiled into the final binary or library. [dev-dependencies] are only available while running cargo test or cargo bench — a testing/benchmarking helper crate has no business being pulled into a shipped release binary, and Cargo keeps that distinction explicit rather than leaving it to convention.
Semantic versioning
A dependency's version requirement isn't (usually) a single exact version — it's a constraint on which versions are acceptable, following semantic versioning (MAJOR.MINOR.PATCH, where a major bump signals a breaking change):
| Specifier | Meaning | Matches | Doesn't match |
|---|---|---|---|
"1.2.3" (same as "^1.2.3") |
Compatible updates — same major version | 1.2.4, 1.9.0 | 2.0.0 |
"~1.2.3" |
Patch-level updates only | 1.2.4 | 1.3.0 |
"=1.2.3" |
Exactly this version, nothing else | 1.2.3 | 1.2.4 |
"*" |
Any version at all (rare, discouraged) | anything | — |
The bare "1.0" shorthand seen above is the default (caret) behavior — it accepts any later 1.x.y release, trusting that the crate author follows semantic versioning and doesn't introduce breaking changes without bumping the major version.
Adding dependencies with cargo add
cargo add serde --features derive
cargo add rand@0.8
cargo add edits Cargo.toml for you (no manual editing required) and immediately fetches the crate's metadata to pick a sensible version constraint. It's the modern equivalent of hand-typing a dependency line and hoping the version number is right.
Cargo.lock — exact, reproducible versions
Cargo.toml states acceptable ranges; Cargo.lock records the exact version of every dependency (direct and transitive) that was actually resolved and used for a build. The first cargo build in a project generates it; every subsequent build reuses the locked versions until something explicitly asks Cargo to re-resolve:
cargo update # re-resolve every dependency to the newest version still allowed by Cargo.toml
cargo update -p serde # re-resolve just one dependency
The common convention: commit Cargo.lock for a binary application, so every developer and every deployment builds against the exact same dependency versions. For a library crate, it's more common not to commit it, since the library's actual consumers will resolve their own compatible versions anyway — though committing it isn't wrong, just less common practice.
The core commands, recapped
cargo build # compile (debug profile) — output in target/debug/
cargo build --release # compile with full optimizations — output in target/release/
cargo run # build (if needed) and run in one step
cargo check # type-check only, no binary produced — much faster while iterating
cargo test # run all unit and integration tests
crates.io and the ecosystem
crates.io is Rust's central package registry — the default source Cargo fetches dependencies from, analogous to npm or RubyGems. A handful of crates are so widely used they're close to a de facto standard library extension:
serde— the standard framework for serializing/deserializing Rust data structures (to/from JSON, YAML, and more), almost universally paired withserde_json.tokio— the dominant asynchronous runtime, providing the event loop that powersasync/awaitcode that does real I/O.rand— random number generation, since the standard library deliberately doesn't include one.clap— declarative command-line argument parsing.reqwest— a high-level HTTP client built on top oftokio.
Workspaces, briefly
A workspace groups several related crates under one shared Cargo.lock and one shared target/ build directory, so they're built together and can depend on each other locally without being published anywhere:
# Cargo.toml at the workspace root — this file has no [package] section of its own
[workspace]
members = ["core", "cli", "server"]
my-project/
├── Cargo.toml # the workspace root, listing members
├── core/ # a shared library crate
│ ├── Cargo.toml
│ └── src/lib.rs
├── cli/ # a binary crate that depends on `core`
│ ├── Cargo.toml
│ └── src/main.rs
└── server/ # another binary crate, also depending on `core`
├── Cargo.toml
└── src/main.rs
This is the standard shape for a project that's really "one library plus several things that use it" — a shared core crate holding business logic, with cli and server each depending on it locally (core = { path = "../core" }) instead of duplicating code or publishing an internal-only crate to crates.io just to consume it.
Common mistakes
- Manually hand-editing
Cargo.lockinstead of runningcargo update— it's a generated, machine-maintained file, not meant to be edited directly. - Pinning every dependency to an exact version (
"=1.2.3") out of excess caution, which blocks routine patch and security fixes from ever being picked up automatically — the default caret behavior exists precisely so bug fixes flow in without manual bumps. - Bumping a version number in
Cargo.tomland expecting the new dependency to be used immediately, forgetting that an existingCargo.lockwill keep resolving to the old locked version untilcargo updateis run for that dependency. - Adding a crate that's only used in tests or benchmarks under
[dependencies]instead of[dev-dependencies], unnecessarily bloating the compiled release binary's dependency tree.
Interview questions
Q: What's the difference between Cargo.toml and Cargo.lock?
Cargo.toml declares dependencies as version ranges ("compatible with 1.2, whatever that resolves to") along with project metadata. Cargo.lock records the exact versions actually resolved for every dependency, direct and transitive, so a build can be reproduced identically later. Cargo.toml is written by hand (or via cargo add); Cargo.lock is generated and updated by Cargo itself.
Q: What does the default caret (^) version requirement mean, and why is it Cargo's default?
"1.2.3" (equivalent to "^1.2.3") accepts any later version with the same major version number — 1.2.4 or 1.9.0, but not 2.0.0. It's the default because semantic versioning promises a major-version bump is the only place a breaking change is allowed to happen, so accepting any minor/patch update within the same major version is expected to be safe and lets bug fixes and small improvements flow in without manual intervention.
Q: What is a Cargo workspace, and why would you reach for one?
A workspace groups multiple related crates so they share one Cargo.lock and one build output directory, and can depend on each other via local paths without needing to be published anywhere. It's the standard structure for a project that's really one shared library plus several binaries or services built on top of it, avoiding both code duplication and the overhead of publishing internal-only crates just to consume them elsewhere in the same project.