Cargo and Crates

Every Rust project beyond a single toy file relies on Cargo, Rust’s official build tool and package manager. Cargo turns a folder of source files into a reproducible, compilable package by tracking your project’s metadata, invoking the compiler correctly, and downloading and linking external libraries called crates. Without it you would run rustc by hand and manage every dependency’s source yourself; with it, adding a library is a one-line change to a text file. This lesson covers what Cargo and crates are, how the build process works under the hood, and the mistakes beginners make when depending on outside code.

Overview: How Cargo and Crates Work

Rust’s compiler, rustc, only knows how to compile one crate at a time. A crate is the smallest unit of code the compiler treats as a single compilation, rooted at one file — main.rs for an executable, lib.rs for a library — plus every module it pulls in with mod. On its own, rustc has no concept of a “dependency,” no way to fetch one from the internet, and no memory of which exact version you used last time. Cargo is the layer on top of rustc that solves all of that: it defines a package (one or more crates described by a single manifest file), resolves and downloads any external crates your code needs, works out the correct order to compile everything, and finally invokes rustc the right number of times with the right flags.

Think of Cargo as doing three jobs that npm, pip, or Maven do for other languages, but wired directly into the compiler: a project scaffolder (cargo new), a package manager that talks to the public registry crates.io, and a build system that compiles large dependency graphs efficiently and incrementally. Every Cargo package has a Cargo.toml manifest at its root describing its name, version, edition, and dependencies. The first time you build a package with dependencies, Cargo resolves every dependency’s version — and the versions of their dependencies, and so on — into one consistent set, then writes the exact result, including transitive dependencies you never named yourself, into a second file: Cargo.lock. That lockfile is what makes “it compiles on my machine” also compile on yours: as long as Cargo.lock is present, everyone builds the exact same dependency versions, no matter what has been published to crates.io since.

Version numbers in Cargo.toml follow semantic versioning. Writing rand = "0.8" is shorthand for a caret requirement, ^0.8.0, meaning “any 0.8.x release, but not 0.9.0” — before 1.0, a semver-conscious crate treats even minor bumps as potentially breaking. Once a crate reaches 1.0, serde = "1.0" accepts any 1.x.y release, since only major version bumps are allowed to break compatibility.

Syntax: The Cargo Command Line and Cargo.toml

Command What it does
cargo new my_app Scaffolds a new binary package in a new my_app/ directory (add --lib for a library)
cargo build Compiles the package and its dependencies into target/debug/
cargo run Builds (if needed) and then runs the resulting binary
cargo check Type-checks and borrow-checks without producing a binary — much faster, ideal while iterating
cargo test Compiles and runs every function annotated #[test]
cargo add rand Adds a dependency to [dependencies] in Cargo.toml automatically
cargo update Re-resolves dependency versions within their existing constraints and rewrites Cargo.lock
cargo doc --open Builds HTML API docs for your crate and its dependencies and opens them
cargo build --release Compiles with optimizations into target/release/

A minimal Cargo.toml has two tables:

  • [package] — metadata: name, version, edition (this course targets "2021").
  • [dependencies] — one line per crate, either a bare version string (serde = "1.0") or a table for extra options (serde = { version = "1.0", features = ["derive"] }).

Examples

Example 1: Scaffolding and running a project

cargo new creates the whole package layout for you: a manifest plus a src/ directory with a starter main.rs.

hello_cargo/
├── Cargo.toml
└── src/
    └── main.rs
[package]
name = "hello_cargo"
version = "0.1.0"
edition = "2021"

[dependencies]

The generated src/main.rs looks like this:

fn main() {
    println!("Hello, Cargo!");
}

Output:

Hello, Cargo!

Running cargo run from inside the package directory shows the whole pipeline in one command:

$ cargo new hello_cargo
     Created binary (application) `hello_cargo` package
$ cd hello_cargo
$ cargo run
   Compiling hello_cargo v0.1.0 (/home/you/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.38s
     Running `target/debug/hello_cargo`
Hello, Cargo!

Cargo compiled the crate (because no cached build existed yet) and then executed the resulting binary, streaming its println! output straight to your terminal.

Example 2: The standard library is a crate too

You never add std to [dependencies] — it ships with every Rust installation — but it is still organized as a crate with modules you bring into scope with use, exactly like an external one.

use std::collections::HashMap;

fn main() {
    let mut crate_versions: HashMap<String, String> = HashMap::new();
    crate_versions.insert(String::from("rand"), String::from("0.8.5"));
    crate_versions.insert(String::from("serde"), String::from("1.0.197"));

    let mut names: Vec<&String> = crate_versions.keys().collect();
    names.sort();

    for name in names {
        let version = crate_versions.get(name).unwrap();
        println!("{name} = \"{version}\"");
    }
}

Output:

rand = "0.8.5"
serde = "1.0.197"

This builds a HashMap<String, String>, collects its keys into a Vec<&String> so they can be sorted (hash maps have no defined iteration order), then looks each version back up. Notice that get is called with .unwrap() here only because we just inserted both keys ourselves, so we know the lookup can’t fail — in real code you would usually match on the Option instead.

Example 3: Declaring an external dependency

To use a crate from crates.io, add it to [dependencies] first, then bring its items into scope with use.

[package]
name = "dice_roller"
version = "0.1.0"
edition = "2021"

[dependencies]
rand = "0.8"
use rand::Rng;

fn main() {
    let mut rng = rand::thread_rng();
    let roll: u32 = rng.gen_range(1..=6);
    println!("You rolled a {roll}!");
}

Output:

You rolled a 4!

(The exact number is random between 1 and 6 and differs every run — this snippet is shown for illustration since it needs the rand crate downloaded from crates.io, which this lesson’s compile check does not fetch.) The first time you run cargo build after adding rand, Cargo downloads it and every crate it depends on, compiles them, and records the exact versions chosen in Cargo.lock.

How Cargo Works Step by Step

  1. You run cargo build or cargo run.
  2. Cargo reads Cargo.toml and builds the dependency graph from your [dependencies] entries.
  3. If Cargo.lock already exists, Cargo reuses those exact pinned versions; otherwise it resolves the newest versions that satisfy every semver constraint in the graph and writes the result to Cargo.lock.
  4. Cargo downloads (or reuses a cached copy of) each dependency’s source into ~/.cargo/registry.
  5. Cargo topologically orders the graph so dependencies are compiled before the crates that use them, then invokes rustc once per crate, producing an intermediate library artifact for each.
  6. Your own crate root (main.rs) is compiled last, linking in the already-compiled dependency artifacts and the standard library, producing an executable in target/debug/ (or target/release/ with --release).
  7. All intermediate artifacts are cached in target/, so a later build only recompiles crates whose source actually changed — the first build after adding a dependency is slow, later ones are fast.
  8. cargo run performs all of the above and then executes the resulting binary, forwarding its stdout and stderr to your terminal.

Common Mistakes

Mistake 1: Using a crate without declaring it

Writing use rand::Rng; does nothing on its own — Cargo only links crates that are actually listed under [dependencies]. If rand was never added to Cargo.toml, this fails to compile:

use rand::Rng;

fn main() {
    let n: u32 = rand::thread_rng().gen_range(1..=6);
    println!("You rolled a {n}!");
}
error[E0433]: failed to resolve: use of unresolved module or unlinked crate `rand`
 --> src/main.rs:1:5
  |
1 | use rand::Rng;
  |     ^^^^ use of unresolved module or unlinked crate `rand`
  |
  = help: if you wanted to use a crate named `rand`, run `cargo add rand` first

The fix is to add rand = "0.8" under [dependencies] in Cargo.toml (or run cargo add rand, which edits the file for you) before building again. This is a compile-time failure, not a runtime one: Cargo resolves the whole dependency graph before rustc ever looks at your code, so a missing dependency is caught immediately.

Mistake 2: Forgetting mut on a value you intend to mutate

Bindings are immutable by default in Rust. Calling a mutating method like push on a binding that wasn’t declared mut is rejected by the compiler, not silently allowed:

fn main() {
    let dependencies: Vec<String> = Vec::new();
    dependencies.push(String::from("serde"));
    println!("{dependencies:?}");
}
error[E0596]: cannot borrow `dependencies` as mutable, as it is not declared as mutable
 --> src/main.rs:3:5
  |
2 |     let dependencies: Vec<String> = Vec::new();
  |         ------------ help: consider changing this to be mutable: `mut dependencies`
3 |     dependencies.push(String::from("serde"));
  |     ^^^^^^^^^^^^ cannot borrow as mutable

Adding mut to the binding fixes it:

fn main() {
    let mut dependencies: Vec<String> = Vec::new();
    dependencies.push(String::from("serde"));
    println!("{dependencies:?}");
}
["serde"]

This is unrelated to Cargo itself, but it is one of the very first errors newcomers hit while experimenting inside a freshly-scaffolded Cargo project, so it is worth recognizing on sight.

Best Practices

  • Commit Cargo.lock for binary applications so every machine builds identical dependency versions; library crates meant to be reused by others are typically not committed with a lockfile, since Cargo regenerates one for each consumer.
  • Reach for cargo check while iterating — it runs the full type checker and borrow checker without producing a binary, so feedback is much faster than cargo build.
  • Run cargo clippy regularly; it flags non-idiomatic patterns and likely bugs beyond what rustc alone catches.
  • Run cargo fmt before committing so formatting stays consistent across a team.
  • Prefer the narrowest dependency version you actually need, and run cargo update deliberately rather than letting versions drift unnoticed.
  • Keep dependency count and enabled features minimal — each one adds compile time and audit surface; use default-features = false plus an explicit features list when a crate supports it.
  • Once a project grows into several related crates, group them under one root [workspace] Cargo.toml so they share a single Cargo.lock and target/ directory.

Practice Exercises

  1. Run cargo new greeter, edit src/main.rs to build a String greeting and println! it, then run the project with cargo run. Confirm a target/ directory appears after building.
  2. Create a new package, add rand as a dependency, and write a program that prints a random number between 1 and 100 using rand::Rng::gen_range. Run it twice and notice the printed number changes while the rand version pinned in Cargo.lock does not.
  3. Predict, then verify: if you change a dependency’s requirement in Cargo.toml from "1.0" to "2.0" and run cargo build again, what happens to the corresponding entry in Cargo.lock?

Summary

  • Cargo is Rust’s build tool and package manager; a crate is the unit rustc compiles, and a package is what Cargo manages via one Cargo.toml.
  • Cargo.toml declares a package’s metadata and dependencies; Cargo.lock pins the exact resolved version of every dependency, direct and transitive, for reproducible builds.
  • crates.io is the central registry Cargo downloads published crates from, using semantic-versioning rules to pick compatible versions automatically.
  • cargo new, build, run, check, test, clippy, and fmt cover almost all day-to-day workflow; learn cargo check first since it gives the fastest feedback loop.
  • Using a crate you haven’t declared in [dependencies] is a compile-time error — Cargo resolves the whole dependency graph before rustc ever runs.
  • Commit Cargo.lock for binaries, keep dependencies minimal, and reach for a workspace once a project spans multiple related crates.