When dev and release aren't enough
You are debugging a race condition. The bug only appears when the code runs fast enough for the threads to overlap. You build with the default settings, and the race window closes up. The code runs too slowly to trigger the bug. You crank up optimization to release, and the compiler takes twenty minutes to rebuild. You cannot iterate at that speed. You are stuck between a build that is too slow to debug and a runtime that is too slow to reproduce the failure.
Cargo profiles solve this by letting you define named sets of compiler flags. You get fast iteration in development, tuned performance for staging, and maximum speed for production, all from the same source tree. You switch profiles with a single flag, and Cargo adjusts the compiler behavior to match.
What a profile actually is
A Cargo profile is a named collection of compiler settings. Think of it like a preset on a camera. You have a "Portrait" mode that blurs the background and boosts skin tones, and a "Landscape" mode that sharpens everything and adjusts contrast. You do not tweak ISO and aperture manually every time. You pick the preset that matches the shot.
Rust comes with two built-in presets. The dev profile is the default. It prioritizes fast compilation and includes debug symbols. The release profile prioritizes runtime performance. It enables aggressive optimizations and strips debug information. You can add your own profiles, like staging or bench, to fill the gaps between these two extremes.
Profiles live in Cargo.toml. They are part of the project configuration, not your local environment. This means the settings travel with the code. Every developer and every CI runner gets the exact same build behavior when they use the same profile.
Minimal custom profile
Define a custom profile by adding a [profile.<name>] section to Cargo.toml. The name can be anything that is not dev or release. You build with the profile using cargo build --profile <name>.
The best practice is to inherit from an existing profile. Inheritance copies all settings from the base profile, then applies your overrides. This prevents copy-paste errors and ensures you get new default settings automatically when Cargo updates.
# Cargo.toml
# The dev profile is the default.
# opt-level = 0 means no optimization. The compiler prioritizes speed of compilation.
# debug = true keeps debug symbols so your debugger can map back to source lines.
[profile.dev]
opt-level = 0
debug = true
# Staging is a custom profile.
# inherits = "dev" copies all settings from dev, then we override what we need.
# This prevents copy-paste errors when Cargo adds new settings in future versions.
[profile.staging]
inherits = "dev"
# opt-level = 2 enables a middle tier of optimizations.
# This improves runtime speed without the full compile-time cost of level 3.
opt-level = 2
Build the staging profile with:
cargo build --profile staging
Cargo reads the staging section, inherits the dev settings, and applies opt-level = 2. The result is a binary that runs faster than dev but still retains debug symbols. You can step through this binary in GDB or LLDB, and the performance is close enough to production to catch timing-sensitive bugs.
How Cargo merges settings
When you specify a profile, Cargo starts with the base settings. If you use inherits, Cargo loads the parent profile first. Then it applies the overrides from your custom profile. Keys in the child profile replace keys in the parent. Keys not mentioned in the child keep their parent values.
This merge happens recursively. If staging inherits dev, and dev inherits the built-in defaults, Cargo walks the chain. The final set of flags is passed to rustc. The compiler sees the combined configuration and adjusts its behavior accordingly.
You can inspect the effective settings for any profile using cargo build --profile <name> -v. The verbose output shows the exact rustc command line. Look for -C opt-level=... and -C debuginfo=... to verify the settings. This is useful when debugging profile inheritance or checking that a CI environment is using the expected flags.
Realistic production setup
Production builds often need more than just optimization levels. You might want Link Time Optimization (LTO) to squeeze out extra speed. You might want to abort on panic to reduce binary size. You might want to strip symbols to minimize the download size.
A realistic production profile inherits from release and adds these tweaks. Inheriting from release is the convention for production-like profiles. It ensures you get the community standard for optimized builds.
# Cargo.toml
# Production profile. Maximum performance, smallest binary.
# inherits = "release" is the standard base for custom prod profiles.
[profile.prod]
inherits = "release"
# lto = true enables Link Time Optimization.
# The compiler optimizes across crate boundaries.
# This can significantly reduce binary size and improve speed, but increases link time.
lto = true
# panic = "abort" stops the program immediately on panic.
# It avoids the cost of unwinding the stack.
# Use this in production to save space and time.
panic = "abort"
# strip = "symbols" removes debug symbols from the final binary.
# This makes the binary smaller.
# You lose the ability to debug the binary on the target machine.
strip = "symbols"
# codegen-units = 1 forces the compiler to treat the crate as a single unit.
# This can improve optimization quality, especially with LTO.
# It slows down compilation, so keep it only in production profiles.
codegen-units = 1
Build with cargo build --profile prod. The binary will be smaller and faster than a standard release build. The trade-off is a longer build time. LTO and codegen-units = 1 both increase the work the compiler must do. This is acceptable for production, where you build once and deploy many times.
Convention aside: use strip = "symbols" rather than strip = true. The true value is deprecated and less precise. symbols explicitly removes debug symbols while keeping other metadata. This is the modern standard.
The debug vs debug-assertions trap
Two settings sound similar but do different things. debug controls debug symbols. debug-assertions controls assert! macros. Confusing them is a common pitfall.
The debug setting tells the compiler whether to emit DWARF debug information. When debug = true, you can use a debugger to inspect variables and step through code. When debug = false, the binary runs the same, but you cannot debug it.
The debug-assertions setting tells the compiler whether to include assert! checks. When debug-assertions = true, assert! macros execute at runtime. When debug-assertions = false, the compiler removes assert! calls entirely. The code runs faster, but you lose the safety checks.
The release profile sets debug-assertions = false by default. If your staging profile inherits from release, all your assertions vanish. You might ship a bug that the assertion would have caught. Always set debug-assertions = true in staging if you rely on assertions for validation.
# Cargo.toml
# Staging profile based on release.
# We want optimizations, but we also want assertions to catch bugs.
[profile.staging]
inherits = "release"
# Re-enable assertions that release disables by default.
debug-assertions = true
# Keep debug symbols for investigation.
debug = true
Check debug-assertions before you trust a staging build. Silent assertions are a silent killer.
Pitfalls and errors
Profiles are powerful, but they have traps. Here are the common mistakes.
If you set a key that does not exist, Cargo warns you. The warning looks like warning: unknown field 'opt-level' in profile 'staging'. This usually means a typo in the key name. Fix the typo, and the warning disappears.
If you set a value that is out of range, Cargo rejects the build. For example, opt-level = 4 is invalid. The valid values are 0, 1, 2, 3, s, and z. Cargo emits an error like error: invalid value for 'opt-level'. Use s for size optimization or z for aggressive size optimization. These are useful for embedded targets where binary size is critical.
If you define a profile without inherits, you might miss default settings. Cargo adds new profile keys over time. If you write every key manually, your profile stays frozen at the old defaults. Use inherits to stay current.
If you rely on RUSTFLAGS environment variables, you lose reproducibility. RUSTFLAGS lives in your shell, not your code. It vanishes when you switch machines or run in CI. Use profiles for settings that should be part of the project. Reserve RUSTFLAGS for temporary debugging or toolchain-specific workarounds.
Convention aside: cargo fmt formats Rust code, not TOML. Your Cargo.toml formatting is up to you. The community does not have a strict style guide for TOML. Keep it readable, and use comments to explain non-obvious settings.
Decision matrix
Use dev when you are iterating on code and want the fastest compile times. The lack of optimization makes the compiler work less, and debug symbols make debugging easier.
Use release when you are building for final deployment and need maximum performance. The compiler spends more time optimizing, and the binary runs faster.
Use a custom profile like staging when you need a middle ground. You want optimizations to catch performance regressions or race conditions, but you still need debug symbols for investigation.
Use inherits = "dev" as the base for custom development profiles. This ensures you get new default settings automatically when Cargo updates.
Use inherits = "release" as the base for custom production profiles. This keeps your production settings aligned with the community standard for optimized builds.
Use lto = true only in production profiles. Link Time Optimization adds significant time to the build process and is rarely worth it during development.
Use panic = "abort" in production when binary size or startup time is critical. It reduces the binary footprint and avoids the cost of stack unwinding.
Use strip = "symbols" in production to remove debug information from the binary. This reduces file size for distribution.
Use debug-assertions = true in staging profiles that inherit from release. This preserves your safety checks while still enabling optimizations.
Use codegen-units = 1 in production profiles when you need the absolute best optimization quality. It slows down compilation, so avoid it in development.
Inherit defaults. Override only what you need. Let Cargo handle the rest.