When the default build isn't enough
You're debugging a tricky logic error. You run cargo run, but the program finishes in 50 milliseconds. You can't attach the debugger. You can't even see the print statements scroll by. You try turning on optimizations, but now the compiler takes four minutes to build, and your iteration loop is dead. You're stuck between a build that runs too fast to debug and a build that compiles too slowly to use.
Cargo profiles solve this. They let you tune the compiler knobs for exactly the situation you're in, without rewriting build scripts or passing cryptic flags every time.
What a profile actually is
A profile is a named set of configuration options that tells rustc how to compile your code. Cargo ships with built-in profiles like dev and release. You can override their settings or create entirely new ones in Cargo.toml.
Think of profiles like presets on a camera. You don't manually adjust aperture, shutter speed, and ISO for every shot. You pick "Portrait" for people, "Sports" for action, or "Manual" when you need total control. Cargo profiles work the same way. The dev profile is optimized for fast compilation and debuggability. The release profile is optimized for execution speed and binary size. Custom profiles let you dial in specific trade-offs for continuous integration, benchmarking, or embedded targets.
The minimal override
The most common tweak is speeding up the development build. The default dev profile sets opt-level = 0. This means the compiler skips almost all optimizations. The build is fast, but the resulting binary runs slowly.
You can bump the optimization level slightly to get a faster runtime without killing compile times. Add this to your Cargo.toml:
[profile.dev]
# opt-level = 1 enables basic optimizations.
# The binary runs noticeably faster, but compile time increases only slightly.
opt-level = 1
This is a community convention. Many Rust developers keep opt-level = 1 in their dev profile permanently. It makes debugging easier because the program doesn't race past breakpoints, and the compile overhead is usually acceptable on modern hardware.
Don't guess the trade-offs. Measure them. If opt-level = 1 adds more than ten seconds to your build, drop it back to zero.
How Cargo applies profiles
When you run cargo build, Cargo looks for the dev profile. When you run cargo build --release, it looks for release. You can target any profile with cargo build --profile <name>.
Cargo merges settings from a few sources. The built-in defaults form the base. Your Cargo.toml overrides specific keys. If you define a custom profile, you can inherit from an existing one using inherits. The inheritance chain resolves conflicts by taking the most specific value.
[profile.ci]
# Inherit everything from release, then tweak specific settings.
inherits = "release"
# Turn off LTO to save CI time.
lto = false
# Strip debug symbols to reduce artifact size.
strip = "symbols"
The ci profile here gets all the optimizations from release, but skips Link Time Optimization and strips symbols. This is perfect for a CI runner that needs fast tests but doesn't care about binary size or debug info.
Profiles are lies you tell the compiler to get the speed you need. Make sure the lies match your reality.
The knobs you actually care about
Profiles expose several keys. You rarely need all of them. Focus on the ones that impact your workflow.
opt-level
Controls optimization intensity. Values range from 0 to 3, plus s and z.
0: No optimizations. Fastest compile. Slowest runtime. Default fordev.1: Basic optimizations. Good balance. Common dev tweak.2: Aggressive optimizations. Default forrelease.3: Even more aggressive. Rarely worth it. Can increase compile time significantly with minimal speed gain.s: Optimize for size. Shrinks binary. Can slow down execution.z: Optimize for size, aggressively. Best for embedded or WebAssembly where code size matters more than speed.
debug
Controls debug information. This affects whether you can use a debugger and stack traces.
true: Full debug info. Default fordev.false: No debug info. Default forrelease."line-tables-only": Includes line numbers for stack traces but omits variable info. Reduces binary size while keeping readable panics."limited": Reduced debug info. Faster compile thantrue, but still usable with debuggers.
Setting debug = false in dev breaks your debugger. You won't be able to inspect variables or set breakpoints. If you need faster builds but still want stack traces, use debug = "line-tables-only".
lto
Link Time Optimization. The compiler optimizes across crate boundaries.
false: No LTO. Fastest link time.trueor"fat": Full LTO. Slower link time, smaller binary, faster runtime."thin": Thin LTO. Faster than fat LTO, still gets most benefits.
Fat LTO can make linking take minutes for large projects. "thin" is often the sweet spot. Many projects use lto = "thin" in release builds.
codegen-units
Controls parallelism during compilation. Higher values mean more parallelism but less optimization.
1: Maximum optimization. Slowest compile.16: Default fordev. Fast compile.1: Default forreleaseis often1or low number.
Setting codegen-units = 1 in release can squeeze out extra performance, but it kills parallelism. Only do this for the final binary, not for iterative builds.
panic
Controls panic behavior.
"unwind": Unwinds the stack. Default. Allowscatch_unwind. Larger binary."abort": Terminates immediately. Smaller binary. Faster panic. Nocatch_unwind.
Use panic = "abort" for final binaries. It reduces size and avoids unwinding overhead. You lose the ability to catch panics, but panics should be rare in production code.
strip
Strips symbols from the binary.
false: Keep symbols."symbols": Strip debug symbols."debuginfo": Strip debug info but keep symbols for backtraces.
Use strip = "symbols" for release builds to reduce size. Use strip = "debuginfo" if you want smaller binaries but still need stack traces.
Realistic example: The CI profile
Continuous integration pipelines have different needs than local development. You want fast builds, but you also want to catch performance regressions. You don't need debug info, but you might want optimizations.
A common pattern is a ci profile that inherits from release but disables expensive optimizations.
[profile.ci]
inherits = "release"
# Disable LTO to speed up CI builds.
lto = false
# Reduce codegen units for better optimization than dev, but faster than release.
codegen-units = 16
# Strip symbols to save disk space on artifacts.
strip = "symbols"
Run tests with cargo test --profile ci. This gives you optimized tests without the link-time tax. Your CI runs faster, and you still catch bugs that only appear with optimizations enabled.
If your CI profile breaks your local debug workflow, your profile is too aggressive.
Pitfalls and gotchas
Profile misconfiguration leads to subtle issues. Watch out for these.
Debugging fails because debug info is missing. If you set debug = false in dev, your debugger stops working. You'll see assembly instead of source code. Restore debug = true or use "line-tables-only".
Benchmarks lie because optimizations are off. Running benchmarks with cargo bench uses the bench profile, which inherits from release. If you manually run cargo run --release to benchmark, you're fine. If you run cargo run without --release, you're measuring unoptimized code. Your benchmark results will be meaningless. Always benchmark with --release or a profile that inherits from release.
Compile times explode because of LTO. Adding lto = true to dev can turn a ten-second build into a three-minute build. LTO is expensive. Keep it in release or custom profiles.
Binary size surprises. opt-level = 3 can sometimes increase binary size compared to 2. opt-level = "z" is the way to go for size. Don't assume higher optimization always means smaller code.
Panic strategy changes behavior. Switching to panic = "abort" means catch_unwind no longer works. If your code relies on catching panics, this breaks. Audit your code before changing the panic strategy.
Treat the profile as a contract with the compiler. If you change the contract, verify the behavior matches your expectations.
When to use which profile
Use dev when you are iterating on code and need fast compile times and full debuggability. Use release when you are building the final binary for distribution and need maximum performance and small size. Use a custom profile when you have specific trade-offs, like a CI profile that balances speed and optimization, or an embedded profile that prioritizes size over speed. Use test and bench profiles when running test suites or benchmarks, as they inherit from dev and release respectively but allow fine-tuning for those specific tasks. Reach for panic = "abort" in release profiles to reduce binary size and avoid unwinding overhead, provided you don't rely on catch_unwind. Reach for lto = "thin" in release profiles when you want cross-crate optimizations without the extreme link times of fat LTO.
Profiles give you control. Use that control to match your workflow, not to fight the compiler.