ZetCode

Rust Cargo

last modified October 25, 2026

In this article we show how to work with Cargo, the Rust build tool and package manager.

Cargo is installed together with the Rust compiler by rustup. It creates new projects, downloads and compiles dependencies, builds and runs programs, runs tests, generates documentation, and publishes libraries to crates.io, the Rust package registry. Almost every Rust project is a Cargo project.

A Cargo project is called a package. A package contains one or more crates, the units of compilation: at most one library crate and any number of binary crates. The package is described by a manifest file named Cargo.toml.

The examples were tested with Cargo 1.97 on Linux. The projects live in a /home/user/projects directory.

Cargo version

We check that Cargo is available.

$ cargo --version
cargo 1.97.1 (c980f4866 2026-06-30)

Creating a project

The cargo new command creates a new package. By default it creates a binary package, an application with a main function. If the directory is not already inside a version control repository, Cargo also initializes a Git repository with a .gitignore file that excludes the target directory. The --vcs none option turns this off.

$ cargo new hello
    Creating binary (application) `hello` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

Project layout

The new package has a manifest and a source directory.

Cargo.toml
[package]
name = "hello"
version = "0.1.0"
edition = "2024"

[dependencies]

The [package] section contains the package name, its version, and the Rust edition. The edition selects a set of language rules; 2024 is the newest one. Different crates in one program can use different editions. The [dependencies] section lists the libraries the package uses; it is empty for now.

src/main.rs
fn main() {
    println!("Hello, world!");
}

Cargo generates a hello world program. By convention, the entry point of a binary crate is src/main.rs. The tree command shows the files of the package.

$ tree hello
hello
├── Cargo.toml
└── src
    └── main.rs

1 directory, 2 files

Building the project

The cargo build command compiles the package. By default, Cargo builds with the dev profile: no optimizations, with debug information. The Finished line shows the profile and the build time. The executable is placed in the target/debug directory, from where we can run it.

$ cargo build
   Compiling hello v0.1.0 (/home/user/projects/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.09s
$ ./target/debug/hello
Hello, world!

Running the project

The cargo run command builds the package if needed and runs the program. Here nothing changed since the last build, so Cargo only runs the existing executable; it keeps track of the source files and rebuilds only what is necessary. With the -q (quiet) option, Cargo prints no status messages of its own, and only the output of the program is shown.

$ cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/hello`
Hello, world!
$ cargo run -q
Hello, world!

Checking the code

The cargo check command type-checks the code without producing an executable. It is much faster than a full build, so it is useful for quick feedback while writing code.

src/main.rs
fn main() {
    let n: i32 = "42";
    println!("{n}");
}

We introduce an error: a string literal is assigned to an i32 variable. Cargo reports the error in the same format as a full build.

$ cargo check
    Checking hello v0.1.0 (/home/user/projects/hello)
error[E0308]: mismatched types
 --> src/main.rs:2:18
  |
2 |     let n: i32 = "42";
  |            ---   ^^^^ expected `i32`, found `&str`
  |            |
  |            expected due to this

For more information about this error, try `rustc --explain E0308`.
error: could not compile `hello` (bin "hello") due to 1 previous error

Release builds

Cargo has two main profiles: dev for development and release for production builds. They differ in optimization level and in some run-time checks.

src/main.rs
use std::hint::black_box;

fn main() {
    let x: u8 = black_box(255);
    let y = x + 1;

    println!("{y}");
}

The program adds 1 to the largest u8 value. The black_box function hides the value from the optimizer; otherwise the compiler would detect the overflow at compile time and refuse to build the program.

In the dev profile, integer overflow is checked and causes a panic. The --release option selects the release profile: the code is optimized and overflow checks are turned off, so the addition wraps around to 0. The executable is placed in the target/release directory. The number in parentheses after 'main' is the thread ID; it differs between runs.

$ cargo run -q

thread 'main' (48500) panicked at src/main.rs:5:13:
attempt to add with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
$ cargo build --release
   Compiling overflow v0.1.0 (/home/user/projects/overflow)
    Finished `release` profile [optimized] target(s) in 0.05s
$ ./target/release/overflow
0

The target directory

Each profile has its own output directory. Besides the executable, the directories contain compiled dependencies and incremental compilation data.

$ tree -L 2 target
target
├── CACHEDIR.TAG
├── debug
│   ├── build
│   ├── deps
│   ├── examples
│   ├── incremental
│   ├── overflow
│   └── overflow.d
└── release
    ├── build
    ├── deps
    ├── examples
    ├── incremental
    ├── overflow
    └── overflow.d

10 directories, 5 files

Customizing profiles

Profile settings can be changed in the manifest.

Cargo.toml
[package]
name = "overflow"
version = "0.1.0"
edition = "2024"

[dependencies]

[profile.release]
overflow-checks = true

We turn on overflow checks in release builds. Other common settings are opt-level (0 to 3, or "s" and "z" to optimize for size), debug, lto for link-time optimization, codegen-units, strip, and panic = "abort". The optimized program now panics as well.

$ cargo run -q --release

thread 'main' (48585) panicked at src/main.rs:5:13:
attempt to add with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Adding dependencies

Libraries from crates.io are added with the cargo add command. We create a dice program that uses the rand crate. Cargo finds the newest version of the crate and lists its optional features; the ones marked with + are enabled by default.

$ cargo add rand
    Updating crates.io index
      Adding rand v0.10.3 to dependencies
             Features:
             + alloc
             + std
             + std_rng
             + sys_rng
             + thread_rng
             - chacha
             - log
             - serde
             - simd_support
             - unbiased
    Updating crates.io index
     Locking 8 packages to latest Rust 1.97.1 compatible versions

Using a dependency

The cargo add command has changed the manifest.

Cargo.toml
[package]
name = "dice"
version = "0.1.0"
edition = "2024"

[dependencies]
rand = "0.10.3"

A new line was added to the [dependencies] section. The version requirement "0.10.3" is a shorthand for "^0.10.3": Cargo may use any semver-compatible version, here 0.10.3 or a later 0.10.x release. Editing the manifest by hand has the same effect as cargo add.

src/main.rs
fn main() {
    let a = rand::random_range(1..=6);
    let b = rand::random_range(1..=6);

    println!("You rolled {a} and {b}, total {}", a + b);
}

The program rolls two dice. The rand::random_range function returns a random value from the given range. The first build downloads and compiles rand together with its own dependencies; later builds reuse the compiled crates.

$ cargo build
   Compiling libc v0.2.191
   Compiling cfg-if v1.0.5
   Compiling rand_core v0.10.1
   Compiling getrandom v0.4.3
   Compiling cpufeatures v0.3.1
   Compiling chacha20 v0.10.2
   Compiling rand v0.10.3
   Compiling dice v0.1.0 (/home/user/projects/dice)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.27s
$ cargo run -q
You rolled 4 and 1, total 5
$ cargo run -q
You rolled 4 and 4, total 8

Cargo.lock

When dependencies are resolved for the first time, Cargo records the exact versions in the Cargo.lock file. Later builds use the locked versions, even if newer compatible releases appear, so the build is reproducible. The cargo update command updates the lock file to the newest compatible versions. The lock file of an application should be committed to version control.

$ grep -A2 'name = "rand"' Cargo.lock
name = "rand"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"

The dependency tree

The cargo tree command shows all dependencies, including the indirect ones. A crate used by several others, such as rand_core, is compiled only once.

$ cargo tree
dice v0.1.0 (/home/user/projects/dice)
└── rand v0.10.3
    ├── chacha20 v0.10.2
    │   ├── cfg-if v1.0.5
    │   ├── cpufeatures v0.3.1
    │   └── rand_core v0.10.1
    ├── getrandom v0.4.3
    │   ├── cfg-if v1.0.5
    │   ├── libc v0.2.191
    │   └── rand_core v0.10.1
    └── rand_core v0.10.1

Command-line arguments

Arguments for the program are written after a double dash. Everything before it is passed to Cargo.

src/main.rs
use std::env;

fn main() {
    let name = env!("CARGO_PKG_NAME");
    let version = env!("CARGO_PKG_VERSION");
    println!("{name} {version}");

    let args: Vec<String> = env::args().skip(1).collect();

    if args.is_empty() {
        println!("Hello, stranger!");
    }

    for arg in args {
        println!("Hello, {arg}!");
    }
}

The program greets the people named on the command line. It also prints its package name and version.

let name = env!("CARGO_PKG_NAME");
let version = env!("CARGO_PKG_VERSION");

Cargo sets several environment variables when it compiles a crate. The env! macro reads them at compile time, so the values from Cargo.toml are built into the executable.

let args: Vec<String> = env::args().skip(1).collect();

The first argument is the path of the program; we skip it. Without the double dash, Cargo would try to interpret options such as --verbose as its own. When we run the executable directly, no separator is needed.

$ cargo run -q
greet 0.1.0
Hello, stranger!
$ cargo run -q -- Peter Lucy
greet 0.1.0
Hello, Peter!
Hello, Lucy!
$ ./target/debug/greet --verbose
greet 0.1.0
Hello, --verbose!

Multiple binaries and examples

A package can contain several programs. Cargo finds them by their location. Besides src/main.rs, every file in src/bin is a separate binary, named after the file. Files in the examples directory are example programs; libraries typically use them to show how the library is used.

With more than one binary, cargo run needs to know which one to run. The binary from src/main.rs is named after the package. The --bin and --example options select the program. A default can be set with the default-run key in the [package] section.

$ tree --noreport
.
├── Cargo.toml
├── examples
│   └── demo.rs
└── src
    ├── bin
    │   └── report.rs
    └── main.rs
$ cargo run -q
error: `cargo run` could not determine which binary to run. Use the `--bin` option to specify a binary, or the `default-run` manifest key.
available binaries: multi, report
$ cargo run -q --bin multi
Hello, world!
$ cargo run -q --bin report
generating report
$ cargo run -q --example demo
this is an example program

Workspaces

A workspace groups several packages that are developed together. The packages share one Cargo.lock file and one target directory, so common dependencies are compiled only once.

Cargo.toml
[workspace]
resolver = "3"

We create a directory named shapes with a manifest that contains only a [workspace] section. A manifest without a [package] section is a virtual manifest. The resolver = "3" line selects the dependency resolver of the 2024 edition. A virtual manifest has no edition of its own, so without this line Cargo warns and falls back to the oldest resolver.

Inside the workspace we create a library package and an application package. The --lib option creates a library with src/lib.rs instead of src/main.rs. Cargo adds both packages to the workspace manifest:

members = ["app","geometry"]
$ cargo new --lib geometry
    Creating library `geometry` package
      Adding `geometry` as member of workspace at `/home/user/projects/shapes`
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
$ cargo new app
    Creating binary (application) `app` package
      Adding `app` as member of workspace at `/home/user/projects/shapes`
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

A library with tests

We write the library code and its tests.

geometry/src/lib.rs
//! Simple geometry functions.

/// Returns the area of a rectangle.
///
/// ```
/// assert_eq!(geometry::rect_area(2.0, 3.0), 6.0);
/// ```
pub fn rect_area(width: f64, height: f64) -> f64 {
    width * height
}

/// Returns the area of a circle.
pub fn circle_area(radius: f64) -> f64 {
    std::f64::consts::PI * radius * radius
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rectangle() {
        assert_eq!(rect_area(3.0, 4.0), 12.0);
    }

    #[test]
    fn circle() {
        assert!((circle_area(1.0) - 3.14159).abs() < 1e-5);
    }
}

The library has two public functions. Functions must be marked pub to be usable from other crates.

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rectangle() {
        assert_eq!(rect_area(3.0, 4.0), 12.0);
    }

Unit tests are functions marked with #[test]. They are placed in a tests module that is compiled only when testing, thanks to #[cfg(test)].

/// ```
/// assert_eq!(geometry::rect_area(2.0, 3.0), 6.0);
/// ```

Comments starting with /// are documentation comments. Code blocks inside them are compiled and run as doc-tests, which keeps the examples in the documentation correct.

The cargo test command builds the package with the test profile and runs the unit tests and the doc-tests. The -p option selects a package in the workspace.

$ cargo test -p geometry
   Compiling geometry v0.1.0 (/home/user/projects/shapes/geometry)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.13s
     Running unittests src/lib.rs (target/debug/deps/geometry-3fbb18aa9b306a0b)

running 2 tests
test tests::circle ... ok
test tests::rectangle ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests geometry

running 1 test
test geometry/src/lib.rs - rect_area (line 5) ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

all doctests ran in 0.14s; merged doctests compilation took 0.13s

Path dependencies

The application uses the library through a path dependency, which we add with the --path option of cargo add.

app/Cargo.toml
[package]
name = "app"
version = "0.1.0"
edition = "2024"

[dependencies]
geometry = { version = "0.1.0", path = "../geometry" }

The dependency has both a version and a path. The path is used for local builds; the version would be used if the application were published to crates.io.

app/src/main.rs
use geometry::{circle_area, rect_area};

fn main() {
    println!("rectangle: {}", rect_area(2.5, 4.0));
    println!("circle: {:.3}", circle_area(2.0));
}

The library is imported under its package name. We add the dependency and run the application from the workspace root. Cargo compiles the library and the application and places the executable in the shared target directory of the workspace.

$ cargo add --path ../geometry
      Adding geometry (local) to dependencies
$ cargo run -p app
   Compiling app v0.1.0 (/home/user/projects/shapes/app)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.05s
     Running `target/debug/app`
rectangle: 10
circle: 12.566

Workspace layout

The finished workspace has one lock file for both packages. The -I target option hides the build directory.

$ tree --noreport -I target
.
├── app
│   ├── Cargo.toml
│   └── src
│       └── main.rs
├── Cargo.lock
├── Cargo.toml
└── geometry
    ├── Cargo.toml
    └── src
        └── lib.rs

Documentation

The cargo doc command generates HTML documentation from the documentation comments. The //! comment at the top of lib.rs documents the crate as a whole. The --no-deps option skips the documentation of dependencies; with --open, Cargo opens the result in a web browser.

$ cargo doc -p geometry --no-deps
 Documenting geometry v0.1.0 (/home/user/projects/shapes/geometry)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.22s
   Generated /home/user/projects/shapes/target/doc/geometry/index.html

Formatting and linting

The cargo fmt and cargo clippy commands are installed with the default rustup profile. The first formats the code; the second reports common mistakes and non-idiomatic code.

src/main.rs
fn main(){
  let vals=vec![1,2,3];
    let mut total=0;
  for i in 0..vals.len() { total+=vals[i]; }
      println!("{}",total);
}

The program works, but its formatting is inconsistent and it uses an index loop. The cargo fmt command rewrites the source files in the standard Rust style and prints nothing.

src/main.rs
fn main() {
    let vals = vec![1, 2, 3];
    let mut total = 0;
    for i in 0..vals.len() {
        total += vals[i];
    }
    println!("{}", total);
}

After formatting, the logic is unchanged. Clippy then suggests iterating over the elements instead of the indexes and using an array instead of a vector. Some suggestions can be applied automatically with cargo clippy --fix.

$ cargo fmt
$ cargo clippy
    Checking tidy v0.1.0 (/home/user/projects/tidy)
warning: the loop variable `i` is only used to index `vals`
 --> src/main.rs:4:14
  |
4 |     for i in 0..vals.len() {
  |              ^^^^^^^^^^^^^
  |
  = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.97.0/index.html#needless_range_loop
  = note: `#[warn(clippy::needless_range_loop)]` on by default
help: consider using an iterator
  |
4 -     for i in 0..vals.len() {
4 +     for <item> in &vals {
  |

warning: useless use of `vec!`
 --> src/main.rs:2:16
  |
2 |     let vals = vec![1, 2, 3];
  |                ^^^^^^^^^^^^^ help: you can use an array directly: `[1, 2, 3]`
  |
  = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.97.0/index.html#useless_vec
  = note: `#[warn(clippy::useless_vec)]` on by default

warning: `tidy` (bin "tidy") generated 2 warnings (run `cargo clippy --fix --bin "tidy" -p tidy -- ` to apply 1 suggestion)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.07s

Cleaning the build

The cargo clean command removes the target directory with all build artifacts.

$ cargo clean
     Removed 15 files, 125.7KiB total

Source

The Cargo Book

The Cargo Book: Profiles

In this article we have worked with Cargo, the Rust build tool and package manager.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Rust tutorials.