The Cargo Guide

Cargo Profiles and Optimization Controls

Why Cargo Profiles Matter

Cargo profiles are the main way to control how your code is compiled for different purposes. They let you trade off compile speed, runtime speed, debug visibility, binary size, and panic behavior.

A useful mental model is:

  • profiles are named build strategies
  • each profile tells Cargo and rustc how aggressively to optimize and what kind of debug or release behavior to produce

This is why profiles are a first-class part of Cargo's manifest format rather than an afterthought.

The Built-In Profiles

Cargo has four built-in profiles:

  • dev
  • release
  • test
  • bench

These are selected automatically by the command you run unless you explicitly choose otherwise.

A simple mental model is:

  • cargo build usually uses dev
  • cargo build --release uses release
  • cargo test uses test
  • cargo bench uses bench

These profiles exist because development, testing, and final production builds usually want different compiler settings.

Where Profiles Are Defined

Profiles are defined in Cargo.toml using [profile.<name>] tables.

Example:

[profile.dev]
opt-level = 0
 
[profile.release]
opt-level = 3

In a workspace, profiles are effectively root-owned. The workspace root is the place where profile policy should be defined.

That means profile configuration is part of repository-level build policy, not just crate-local preference.

A Small Example Project

Suppose we start with a small package:

cargo new profile_demo
cd profile_demo

Manifest:

[package]
name = "profile_demo"
version = "0.1.0"
edition = "2024"

Code:

pub fn sum_up_to(n: u64) -> u64 {
    (0..=n).sum()
}
 
fn main() {
    println!("{}", sum_up_to(1_000_000));
}

This is enough to make profile effects concrete.

profile.dev

The dev profile is the normal development profile. It usually favors faster compile times and easier debugging over maximum runtime performance.

Example:

[profile.dev]
opt-level = 0
debug = true
incremental = true

This is a good fit for the fast edit-check-run loop that dominates everyday development.

Typical command:

cargo build

profile.release

The release profile is for optimized production-style builds.

Example:

[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = "symbols"

Typical command:

cargo build --release

A useful mental model is:

  • dev optimizes for iteration speed
  • release optimizes for runtime behavior and final artifact quality

profile.test and profile.bench

Cargo also has built-in test and bench profiles.

Example:

[profile.test]
opt-level = 0
debug = true
 
[profile.bench]
opt-level = 3
debug = false

The test profile is intended for running tests, while bench is intended for benchmark-oriented builds.

Typical commands:

cargo test
cargo bench

These profiles exist because tests and benchmarks often want different tradeoffs than ordinary development or production builds.

Custom Profiles

Cargo also supports custom user-defined profiles.

Example:

[profile.profiling]
inherits = "release"
debug = true
strip = "none"

Then use it with a command that selects a profile:

cargo build --profile profiling

This is useful when you want something between dev and release, or a special-purpose build like "optimized but still easy to inspect in a profiler."

Why Custom Profiles Are Useful

Real build workflows are often more nuanced than just development versus release. For example, you may want:

  • a profile for local profiling
  • a profile for size-sensitive release candidates
  • a profile for CI timing experiments
  • a profile for reproducible test environments

Custom profiles let you encode those policies explicitly rather than overloading dev or release.

opt-level

The opt-level setting controls optimization aggressiveness.

Example:

[profile.dev]
opt-level = 0
 
[profile.release]
opt-level = 3

Conceptually:

  • lower optimization levels usually compile faster
  • higher optimization levels usually run faster

Cargo also supports values like "s" and "z" for size-focused optimization strategies.

Example:

[profile.release]
opt-level = "s"

This makes opt-level one of the most important knobs for trading compile time against runtime speed or binary size.

debug

The debug setting controls the level of debug information emitted.

Example:

[profile.dev]
debug = true
 
[profile.release]
debug = false

Or a profiling-oriented custom profile:

[profile.profiling]
inherits = "release"
debug = true

A useful mental model is:

  • more debug info helps debugging and profiling tools
  • less debug info usually reduces artifact size

strip

The strip setting controls whether debug symbols or symbol information are stripped from the final artifact.

Example:

[profile.release]
strip = "symbols"

Or:

[profile.release]
strip = "debuginfo"

Or no stripping:

[profile.release]
strip = "none"

This is an important control when balancing debuggability against binary size and shipping concerns.

panic Strategy

The panic setting controls how panics are handled.

Example:

[profile.release]
panic = "abort"

This can reduce binary size and simplify some production builds, but it changes panic behavior in meaningful ways.

A good mental model is:

  • unwind preserves stack unwinding behavior
  • abort treats panic as immediate termination

This is one of the highest-impact semantic choices a profile can make.

LTO

LTO means Link Time Optimization. It can improve final runtime performance or binary size by letting the compiler and linker optimize across crate boundaries.

Example:

[profile.release]
lto = true

Or explicitly:

[profile.release]
lto = "thin"

LTO is usually more relevant in optimized builds than in day-to-day development builds because it can increase build cost.

codegen-units

The codegen-units setting controls how many code generation units the compiler splits work into.

Example:

[profile.release]
codegen-units = 1

A useful mental model is:

  • more codegen units can help compile speed
  • fewer codegen units can help final optimization quality

This means codegen-units often participates in the same overall tradeoff space as opt-level and lto.

incremental

The incremental setting controls whether incremental compilation is used.

Example:

[profile.dev]
incremental = true
 
[profile.release]
incremental = false

Incremental compilation is usually valuable during iterative development because it reuses prior work between builds. It is usually less central for final release builds.

That is why development-oriented profiles and production-oriented profiles often choose differently here.

Debug Assertions and Overflow Checks

Profiles can also control correctness-oriented compiler settings such as debug assertions and overflow checks.

Example:

[profile.dev]
debug-assertions = true
overflow-checks = true
 
[profile.release]
debug-assertions = false
overflow-checks = false

These settings help illustrate that profiles are not just about speed. They are also about what kind of runtime safety and debug visibility you want in a given build mode.

split-debuginfo

Some platforms support split-debuginfo, which controls how debug information is emitted and stored.

Example:

[profile.dev]
split-debuginfo = "unpacked"

This setting is more platform-sensitive than some other profile knobs, but it matters when debugging workflow, binary size, and artifact organization all interact.

rpath

Cargo profiles also support rpath.

Example:

[profile.release]
rpath = false

This is a more specialized setting and is far less commonly adjusted than options like opt-level, debug, or lto, but it is part of the profile surface.

Build Overrides

Cargo supports build overrides for build scripts, proc macros, and their dependencies.

Example:

[profile.dev.build-override]
opt-level = 0
 
[profile.release.build-override]
opt-level = 0

This is useful because build-time tooling often has very different performance needs from the final application artifacts. You may want aggressive optimization for your main crate but not for proc macros or build scripts.

Per-Package Profile Overrides

Cargo also supports package-specific profile overrides.

Example:

[profile.dev.package.regex]
opt-level = 2
 
[profile.release.package.some_heavy_dep]
codegen-units = 1

This lets you treat specific dependencies differently from the rest of the graph when that is useful.

A practical case is when one dependency is especially expensive or especially performance-sensitive.

What Per-Package Overrides Are Good For

Per-package overrides are useful when one crate behaves differently from the rest of the build in ways that matter to you.

For example:

  • a dependency is slow in debug mode and benefits from modest optimization even during development
  • a particular release dependency deserves stricter codegen settings
  • a build script or proc macro should stay cheap even when production artifacts are highly optimized

They are an advanced tool and should usually be used intentionally rather than as a first resort.

A Balanced Local Development Profile Strategy

A common local development strategy is to keep the main dev profile fast, but not necessarily completely unoptimized.

Example:

[profile.dev]
opt-level = 1
debug = true
incremental = true
debug-assertions = true
overflow-checks = true

This can sometimes give a better balance between iteration speed and realistic runtime behavior than a fully unoptimized setup.

A Production-Oriented Release Profile Strategy

A common production-oriented release strategy is to optimize harder and reduce artifact bulk.

Example:

[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
panic = "abort"
strip = "symbols"
incremental = false

This aims for efficient shipped artifacts rather than fast local iteration.

A Profiling-Oriented Custom Profile

A useful custom profile for profiling often inherits from release but keeps debug information.

Example:

[profile.profiling]
inherits = "release"
debug = true
strip = "none"

Then build with:

cargo build --profile profiling

This is a good example of how custom profiles let you encode real operational workflows rather than force everything into only dev or release.

Choosing Profiles for Local Development

For local development, the usual priorities are:

  • fast rebuilds
  • strong debug visibility
  • useful assertions
  • incremental compilation

A typical local command set is:

cargo build
cargo run
cargo test

That means dev and test profiles should usually support an efficient inner loop rather than maximizing final runtime speed.

Choosing Profiles for CI

CI often needs more than one profile strategy.

A fast validation stage may rely on ordinary dev or test behavior:

cargo check --workspace
cargo test --workspace

A release verification stage may build production artifacts explicitly:

cargo build --release

This means CI is often best understood as a combination of profile purposes rather than one globally perfect profile.

Choosing Profiles for Production

For production builds, the usual priorities are:

  • runtime performance
  • binary size
  • predictable panic behavior
  • reduced unnecessary debug payload

A typical production command is:

cargo build --release

If the built-in release profile is not enough, a custom release-like profile can encode a more specific deployment policy.

A Full Example Manifest

Here is a realistic Cargo.toml that demonstrates several profile controls together.

[package]
name = "profile_demo"
version = "0.1.0"
edition = "2024"
 
[profile.dev]
opt-level = 1
debug = true
incremental = true
debug-assertions = true
overflow-checks = true
 
[profile.test]
opt-level = 0
debug = true
incremental = true
 
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
panic = "abort"
strip = "symbols"
incremental = false
 
[profile.bench]
opt-level = 3
debug = false
lto = true
 
[profile.profiling]
inherits = "release"
debug = true
strip = "none"
 
[profile.dev.build-override]
opt-level = 0
 
[profile.dev.package.regex]
opt-level = 2

Code Example to Try with Multiple Profiles

Use a small program like this to experiment:

pub fn sum_up_to(n: u64) -> u64 {
    (0..=n).sum()
}
 
fn main() {
    println!("{}", sum_up_to(1_000_000));
}

Then compare commands:

cargo run
cargo run --release
cargo build --profile profiling
cargo test

This helps make profile differences tangible, even in a simple project.

Common Beginner Mistakes

Mistake 1: assuming dev and release are just faster and slower versions of the same build.

Mistake 2: using release settings for local development and then wondering why iteration feels slow.

Mistake 3: stripping too aggressively and then losing useful debugging or profiling visibility.

Mistake 4: using panic = "abort" without understanding the semantic consequences.

Mistake 5: scattering profile expectations mentally across crates instead of treating profile policy as a top-level build concern.

Mistake 6: changing many knobs at once without a clear goal.

Hands-On Exercise

Create a small package and experiment with profiles.

Start here:

cargo new profile_lab
cd profile_lab

Replace the manifest with a profile-rich version:

[package]
name = "profile_lab"
version = "0.1.0"
edition = "2024"
 
[profile.dev]
opt-level = 1
debug = true
incremental = true
 
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = "symbols"
panic = "abort"
 
[profile.profiling]
inherits = "release"
debug = true
strip = "none"

Use this code:

pub fn work(n: u64) -> u64 {
    (0..=n).sum()
}
 
fn main() {
    println!("{}", work(2_000_000));
}

Then try:

cargo build
cargo run
cargo build --release
cargo run --release
cargo build --profile profiling

This exercise helps connect profile settings to real build modes and workflow intent.

Mental Model Summary

A strong mental model for Cargo profiles is:

  • profiles are named build strategies
  • dev, release, test, and bench exist because different workflows need different tradeoffs
  • custom profiles let you encode real operational needs beyond the built-ins
  • opt-level, debug, strip, panic, lto, codegen-units, and incremental are the core profile levers
  • build overrides and per-package overrides let you tune specific parts of the graph separately
  • local development, CI, and production usually want different profile behavior

Once this model is stable, Cargo profiles become much easier to use as intentional build policy rather than a bag of tuning flags.