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
rustchow 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:
devreleasetestbench
These are selected automatically by the command you run unless you explicitly choose otherwise.
A simple mental model is:
cargo buildusually usesdevcargo build --releaseusesreleasecargo testusestestcargo benchusesbench
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 = 3In 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_demoManifest:
[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 = trueThis is a good fit for the fast edit-check-run loop that dominates everyday development.
Typical command:
cargo buildprofile.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 --releaseA useful mental model is:
devoptimizes for iteration speedreleaseoptimizes 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 = falseThe test profile is intended for running tests, while bench is intended for benchmark-oriented builds.
Typical commands:
cargo test
cargo benchThese 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 profilingThis 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 = 3Conceptually:
- 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 = falseOr a profiling-oriented custom profile:
[profile.profiling]
inherits = "release"
debug = trueA 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:
unwindpreserves stack unwinding behavioraborttreats 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 = trueOr 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 = 1A 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 = falseIncremental 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 = falseThese 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 = falseThis 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 = 0This 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 = 1This 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 = trueThis 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 = falseThis 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 profilingThis 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 testThat 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 --workspaceA release verification stage may build production artifacts explicitly:
cargo build --releaseThis 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 --releaseIf 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 = 2Code 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 testThis 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_labReplace 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 profilingThis 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, andbenchexist 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, andincrementalare 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.
