3 min read
On this page

Cargo & Project Setup

Cargo is Rust's build system, package manager, test runner, and documentation generator. It is the best package manager in any programming language, and that is not hyperbole. It handles dependency resolution, semantic versioning, build profiles, workspaces, and reproducible builds out of the box. Every Rust project uses Cargo. There is no alternative ecosystem to fragment the community.

Creating a New Project

// Binary project (an executable):
// cargo new my-app

// Library project:
// cargo new my-lib --lib
$ cargo new my-app
     Created binary (application) `my-app` package

$ tree my-app
my-app
├── Cargo.toml
├── src
│   └── main.rs
└── .gitignore

That is the entire structure. No config files to generate, no boilerplate to copy, no framework to choose. src/main.rs contains a hello world and you are ready to go.

Cargo.toml

This is the manifest file. It defines your project metadata, dependencies, and build configuration.

[package]
name = "my-app"
version = "0.1.0"
edition = "2021"
authors = ["Your Name <you@example.com>"]
description = "A short description of what this does"

[dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
clap = { version = "4", features = ["derive"] }

[dev-dependencies]
assert_cmd = "2.0"
tempfile = "3.0"

Key sections:

  • [package] — Name, version, edition. The edition field controls which Rust language edition you use (2021 is current).
  • [dependencies] — Runtime dependencies. Cargo downloads these from crates.io automatically.
  • [dev-dependencies] — Dependencies only used in tests and benchmarks. Not included in your final binary.

Adding Dependencies

$ cargo add serde --features derive
    Updating crates.io index
      Adding serde v1.0.197 to dependencies
             Features: + derive

cargo add edits Cargo.toml for you. You can also edit the file directly — both approaches work fine.

The Essential Cargo Commands

$ cargo run              # Compile and run (debug mode)
$ cargo build            # Compile without running
$ cargo build --release  # Compile with optimizations
$ cargo test             # Run all tests
$ cargo check            # Type-check without producing a binary (fast)
$ cargo fmt              # Format your code
$ cargo clippy           # Lint your code (catches common mistakes)
$ cargo doc --open       # Generate and open documentation

cargo check deserves special mention. It runs the full type checker and borrow checker without generating machine code. It is significantly faster than cargo build and is what you should run while developing. Most editors run it automatically on save.

Build Profiles: Dev vs Release

Rust has two default build profiles that behave very differently:

# These are the defaults — you don't need to write them,
# but you can override them in Cargo.toml:

[profile.dev]
opt-level = 0       # No optimization
debug = true        # Full debug info

[profile.release]
opt-level = 3       # Maximum optimization
debug = false       # No debug info
lto = false         # Link-time optimization off by default

The performance difference is dramatic. A debug build might be 10-100x slower than a release build for compute-heavy code.

fn fibonacci(n: u64) -> u64 {
    if n <= 1 {
        return n;
    }
    fibonacci(n - 1) + fibonacci(n - 2)
}

fn main() {
    let start = std::time::Instant::now();
    let result = fibonacci(40);
    println!("fib(40) = {} in {:?}", result, start.elapsed());
}
# Debug build:
$ cargo run
fib(40) = 102334155 in 1.2s

# Release build:
$ cargo run --release
fib(40) = 102334155 in 0.3s

Never benchmark debug builds. Never ship debug builds to production. Always use --release for anything performance-sensitive.

Project Structure

A well-organized Rust project follows conventions that Cargo understands:

my-app/
├── Cargo.toml
├── Cargo.lock          # Exact dependency versions (commit this for binaries)
├── src/
│   ├── main.rs         # Entry point for binaries
│   ├── lib.rs          # Entry point for libraries
│   ├── config.rs       # Module file
│   └── handlers/
│       ├── mod.rs      # Module declaration
│       ├── auth.rs
│       └── api.rs
├── tests/
│   └── integration.rs  # Integration tests
├── benches/
│   └── benchmark.rs    # Benchmarks
└── examples/
    └── demo.rs         # Example programs (cargo run --example demo)

Cargo recognizes all of these directories by convention. No configuration needed.

Workspaces

When your project grows beyond a single crate, workspaces let you manage multiple packages together:

# Root Cargo.toml
[workspace]
members = [
    "crates/core",
    "crates/api",
    "crates/cli",
]

[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
# crates/api/Cargo.toml
[package]
name = "my-api"
version = "0.1.0"
edition = "2021"

[dependencies]
serde.workspace = true
tokio.workspace = true
my-core = { path = "../core" }

Workspaces share a single target/ directory and Cargo.lock. Dependencies are resolved once for the entire workspace. This keeps builds fast and ensures consistency.

Cargo.lock

Cargo.lock pins exact dependency versions. The rule is simple:

  • Binary projects: Commit Cargo.lock. You want reproducible builds.
  • Library crates: Do not commit Cargo.lock. Let downstream users resolve versions.

Useful Cargo Plugins

$ cargo install cargo-watch    # Recompile on file changes
$ cargo install cargo-expand   # Show macro expansions
$ cargo install cargo-audit    # Check for security vulnerabilities
$ cargo install cargo-outdated # Show outdated dependencies

cargo watch is particularly useful during development:

$ cargo watch -x check -x test

This runs cargo check and then cargo test every time you save a file. Fast feedback loops make Rust development much more pleasant.

A Real-World Cargo.toml

[package]
name = "web-service"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres"] }
tracing = "0.1"
tracing-subscriber = "0.3"
anyhow = "1"

[dev-dependencies]
reqwest = { version = "0.12", features = ["json"] }
testcontainers = "0.15"

[profile.release]
lto = true
codegen-units = 1
strip = true

The release profile here enables link-time optimization, reduces codegen units for better optimization, and strips debug symbols. This produces a smaller, faster binary at the cost of longer compile times.

Common Pitfalls

  • Not using cargo check during development. It is much faster than cargo build and catches the same errors. Use it.
  • Benchmarking debug builds. Debug mode disables all optimizations. Your benchmark results are meaningless without --release.
  • Not committing Cargo.lock for binaries. Without it, cargo build on a different machine might resolve different dependency versions and produce a different binary.
  • Adding dependencies without checking their maintenance status. A crate with 10 million downloads but no commits in two years might be stable, or it might be abandoned. Check the repository.
  • Ignoring cargo clippy output. Clippy catches real bugs and suggests idiomatic improvements. Run it in CI.
  • Putting everything in one giant main.rs. Split your code into modules early. It costs nothing and saves you pain later.

Key Takeaways

  • Cargo handles building, testing, dependency management, and documentation in one tool.
  • cargo check is faster than cargo build and should be your default during development.
  • Debug builds are dramatically slower than release builds. Always use --release for benchmarks and production.
  • Workspaces let you manage multi-crate projects with shared dependencies and a single lockfile.
  • The Rust project structure is convention-based. Cargo understands src/, tests/, benches/, and examples/ without configuration.
  • Commit Cargo.lock for binaries, skip it for libraries.