A default Rust release binary is fast and not particularly small, and the usual internet advice to fix that is a block of six settings pasted from a gist. Some of them work, one of them changes what your program does when it panics, and one of the most popular, opt-level = "s", made the binary bigger in the measurement below. This post measures each setting separately on the same program, then combines the ones that earned it, with the catch for each.
The program is a JSON-and-regex loop, built on Cargo 1.99 with edition 2024 on a 10-core Apple M1 Max, with serde_json 1 and regex 1. It is listed in full in profiles explained; the baseline release build is 2,234,928 bytes and compiles clean in about 4.2 seconds.
The measurements, one setting at a time
Each row changes exactly one thing against the default release profile, using the environment form of the profile setting (CARGO_PROFILE_RELEASE_STRIP=true cargo build --release) so no manifest edit is involved. Build times are single clean builds; run time is the best of three, and it was 0.09 s for every row except the two size levels, which ran in 0.11 s ("s") and 0.24 s ("z").
| Change | Binary size | vs baseline | Clean build |
|---|---|---|---|
| baseline (opt-level 3) | 2,234,928 B | 0 | 4.4 s |
strip = true | 1,740,928 B | −22% | 4.4 s |
panic = "abort" | 2,000,880 B | −10% | 4.2 s |
lto = "fat" | 1,766,432 B | −21% | 11.2 s |
codegen-units = 1 | 1,793,440 B | −20% | 7.3 s |
opt-level = "s" | 2,371,392 B | +6% | 3.8 s |
opt-level = "z" | 2,420,928 B | +8% | 3.5 s |
| all four of the first four | 1,345,008 B | −40% | 12.4 s |
Three results are worth reading slowly. strip is the free one: a 22 percent reduction for no build-time cost and no behaviour change, because it removes symbol names a release binary does not need at runtime. lto and codegen-units = 1 each give about the same 20 percent, and the combined build shows they overlap only partly, since together they reach 40 percent with the other two. And the size-oriented opt-level values lost: this program leans on regex, whose speed comes from aggressive inlining, and telling LLVM to optimise for size produced more code, not less. That is one program and one machine, but it is the result that should stop you pasting opt-level = "z" unmeasured; it earns its keep on small embedded code, and the measurement is the only way to know if it helps yours.
The setting that changes behaviour: panic = abort
Every other row above leaves the program's semantics alone. panic = "abort" does not. By default a panic unwinds: Rust walks back up the stack running destructors, and std::panic::catch_unwind can stop it. With abort, the process ends at the panic. Here is a program that depends on the first behaviour:
use std::panic;
fn parse_port(s: &str) -> u16 {
s.parse().expect("port must be a number")
}
fn main() {
let result = panic::catch_unwind(|| parse_port("eighty"));
match result {
Ok(p) => println!("port {p}"),
Err(_) => println!("recovered from a panic"),
}
println!("still running");
}$ cargo run -q --release
thread 'main' panicked at src/main.rs:4:15:
port must be a number: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
recovered from a panic
still running
$ echo $?
0Now add panic = "abort" to [profile.release] and run the identical source:
$ cargo run -q --release
thread 'main' panicked at src/main.rs:4:15:
port must be a number: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
$ echo $?
134The message is identical, but the catch_unwind arm never runs, "still running" never prints, and the exit status is 134, which is 128 plus signal 6, SIGABRT. The program is smaller and, in a long-running server, a panic in one request now kills every other request in flight. Frameworks that catch handler panics to return a 500 (Tokio tasks, some web servers) lose that safety net, and so does any code that relies on destructors running during unwinding to release locks or flush files. For a command-line tool that exits on error anyway, abort costs nothing; for a server, decide on purpose. The test harness always unwinds regardless of the profile, so tests keep passing and will not reveal the difference.
The shape that is safe to paste
A reasonable default for a binary you ship, with the reason beside each line:
[profile.release]
strip = true # 22% smaller, no downside for a shipped binary
lto = "thin" # modest size and speed gain at a small build cost
codegen-units = 1 # ~20% smaller; slower build, so keep it for release only
# panic = "abort" # only for CLIs and services that should die on a panicKeep the aggressive settings out of the profile you use every day. If a release build takes twelve seconds on a small program, it will take minutes on a large one, and you will stop running it locally. The dist approach from the profiles post is cleaner: leave release quick, and define a dist profile that inherits it and adds lto = "fat" and panic = "abort" for the build CI runs when it publishes an artefact.
Settings people expect to help and that did little
opt-level = 2 instead of 3 made the binary 1.3 percent larger (2,263,008 against 2,234,928 bytes) and ran no faster. opt-level = 1 grew it by 8 percent. Raising codegen-units to 256 gave 2,277,632 bytes, 2 percent bigger, and the clean build took 4.4 s against 4.3 s for the default of 16, so on ten cores it saved nothing. strip = "debuginfo" keeps symbol names for backtraces and removes most of the size cost of debug information; strip = true (which means "symbols") removes both and leaves backtraces showing addresses only, which matters if your production panic reports are read by people.
Verify that stripping did what you think. nm target/release/pf | wc -l counts the symbols left in the binary; a stripped one prints a tiny number or an error saying there are no symbols. If you ship stripped binaries and still want readable crash reports, keep the unstripped file from the same build as a release artefact, because the two only line up when they come from one compile.
Check it on your own program
The whole test takes a minute. Build the baseline, record the size with stat -f%z target/release/NAME (macOS) or stat -c%s (Linux), then change one setting through its environment variable and compare. cargo bloat --release --crates adds a breakdown by crate, which tells you whether the bytes are yours or a dependency's, and that is the more useful question: if one crate is half the binary, the right fix may be a feature flag or a lighter dependency, not a compiler setting.
When none of this is worth doing
A server in a container image is rarely limited by 500 KB of binary; the base image dwarfs it, and the work spent trimming usually buys nothing. Shrinking matters for WebAssembly, embedded targets, tools downloaded by thousands of users and Lambda-style cold starts. And everything here is measured on one 2.2 MB program; a larger project with generics-heavy dependencies will show bigger gaps for lto and codegen-units. The settings list is in the profiles reference.



