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.
[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.
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.
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.
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.
[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.
[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.
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.
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.
[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.
//! 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.
[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.
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.
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.
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
In this article we have worked with Cargo, the Rust build tool and package manager.
Author
List all Rust tutorials.