A 70-line word counter built with cargo build --release came out at 477,568 bytes. Six lines of Cargo.toml took it to 302,688 bytes, a 37% cut, without touching the source. Then adding one popular crate with its default features pushed the same program past a megabyte. This guide measures each step on Rust 1.90 with edition 2024, shows which settings do the work and which ones do nothing here, and explains why, so you can predict the effect on your own binary instead of copying a profile from a blog.
Measure first, with one command
Every number in this post is the output of stat on target/release/<name>. Size work without a measurement loop is superstition, so make the loop one line:
cargo build --release && stat -c%s target/release/sz # Linux
cargo build --release && stat -f%z target/release/sz # macOSChange one setting, rebuild, read one number. Keep a table. The program under test counts words from standard input and prints the top five, which is deliberately ordinary: a HashMap, string handling, sorting and formatted output. It uses nothing outside the standard library, so every byte is either our code or std.
use std::collections::HashMap;
use std::io::{self, BufRead};
fn main() {
let mut counts: HashMap<String, usize> = HashMap::new();
for line in io::stdin().lock().lines() {
let line = line.expect("stdin is valid UTF-8");
for word in line.split_whitespace() {
*counts.entry(word.to_lowercase()).or_default() += 1;
}
}
let mut top: Vec<_> = counts.into_iter().collect();
top.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
for (word, n) in top.iter().take(5) {
println!("{n:>6} {word}");
}
}printf 'b a b c a b\n' | ./target/release/sz 3 b
2 a
1 cThe baseline, with Cargo's default release profile (opt-level = 3, no LTO, 16 codegen units, unwinding panics, debug symbols left in), is 477,568 bytes.
Why a Rust hello-world is not small
A C program that prints one line is around 16 KB. The Rust equivalent starts near 400 KB on most Linux and macOS targets, and it is worth knowing where that goes before cutting anything. Three things are in the file that you did not write.
- The standard library is linked statically, including the code for formatting, I/O, panics and the allocator.
- Panic support carries a backtrace printer, which needs symbol and debug information to be useful.
- The symbol table lists every function name so debuggers and profilers can find them.
Only the last one is pure overhead for a shipped binary, and it is the first thing to remove.
Strip the symbols
Stripping deletes the symbol table and debug sections. The program behaves identically; you lose function names in a profiler or a core dump. Cargo does it for you:
[profile.release]
strip = trueThe result is 376,944 bytes, a drop of 100,624 bytes (21%) from one line. strip = true is shorthand for strip = "symbols"; strip = "debuginfo" removes only the DWARF sections and keeps the symbol names, which is a useful middle for a service whose panics you still want to read. Keep an unstripped copy of every release you ship, because a crash report from a stripped binary is unreadable without it. A common pattern is to build once, copy the file to an artifacts directory, then strip a second copy for distribution.
opt-level did almost nothing, and that is the lesson
The folklore says opt-level = "s" or "z" shrinks binaries. On this program it did not:
| Setting added | Bytes | Change from previous |
|---|---|---|
| default release | 477,568 | |
strip = true | 376,944 | -100,624 |
opt-level = "s" | 377,216 | +272 |
opt-level = "z" | 377,232 | +16 |
lto = true | 302,864 | -74,368 |
codegen-units = 1 | 302,864 | 0 |
panic = "abort" | 302,688 | -176 |
Two of those rows are zero or noise, and the reason is the same: opt-level applies to the crates Cargo compiles, and the standard library arrives already compiled, at opt-level = 3, in the toolchain you installed. Our own code here is a few hundred bytes of the total. Changing how it is optimized changes almost nothing, because almost none of the binary is it. This flips the moment you have 200,000 lines of your own code or heavy generic instantiation, where "s" and "z" do cut tens of percent, and it flips again once you rebuild the standard library, which comes later. So the rule is not "use z", it is "measure, because the answer depends on whose code dominates".
LTO reaches into std
Link-time optimization lets the compiler optimize across crate boundaries, which includes calls into the standard library. With it, unused parts of std that the linker could not prove dead are removed, and small functions are inlined and shrunk:
[profile.release]
strip = true
lto = trueThat is the biggest single win after stripping: 302,864 bytes, down 74,368 (20%). The price is build time. Fat LTO (lto = true) considers the whole program and can multiply a release build's duration. lto = "thin" gets most of the benefit in a fraction of the time and is a sensible default for CI; use true for the final artifact, and compare both on your code. Do not assume they tie.
codegen-units and panic = abort
By default Cargo splits a crate into 16 codegen units compiled in parallel, which speeds the build and costs some optimization. Setting codegen-units = 1 lets LLVM see the whole crate at once. Here it changed nothing, because with fat LTO the whole program is already merged. It earns its keep when LTO is off, or thin, and it is still worth trying on a larger crate. If the number does not move, delete the line; a setting that does not change the measurement is build time spent for nothing.
panic = "abort" removes the unwinding tables and landing pads, since a panic now terminates the process instead of running destructors up the stack. It saved 176 bytes here. On a program with many Drop types and thousands of call sites that can unwind, the saving is much larger, commonly several percent. It has behavioral consequences, which is why it is last:
- A panic can no longer be caught with
std::panic::catch_unwind. - A panic in a spawned thread kills the whole process instead of ending only that thread.
#[should_panic]tests do not work, so tests need the default; Cargo ignorespanicin thetestandbenchprofiles for this reason.
For a command-line tool, abort is almost always right. For a server that handles each request in a task and survives a bug in one handler, it is wrong, because the bug takes down every connection.
The profile we ended with
[profile.release]
strip = true
lto = true
codegen-units = 1
panic = "abort"
opt-level = "z"Final size: 302,688 bytes, 174,880 less than the start (37%). The same program, the same output, no source edits. That is where Cargo-level tuning stops for std-only code, and the remaining 300 KB is mostly the standard library.
A dependency outweighs every setting above
To see the scale, replace the counter with a few lines using regex, with the tuned profile left on:
use regex::Regex;
use std::io::{self, BufRead};
fn main() {
let re = Regex::new(r"[A-Za-z']+").expect("pattern is valid");
let mut n = 0usize;
for line in io::stdin().lock().lines() {
let line = line.expect("stdin is valid UTF-8");
n += re.find_iter(&line).count();
}
println!("{n}");
}With regex 1.13 and its default features, the binary is 1,082,800 bytes, more than three and a half times the whole tuned word counter. The default features include full Unicode tables and several matching engines optimized for speed. If your patterns are ASCII, turn most of that off:
[dependencies]
regex = { version = "1", default-features = false, features = ["std", "unicode-perl"] }A tiny program that builds one regex and counts matches in a short string went to 552,400 bytes with those features, against 286,064 for an equivalent program that only parses a number from argv. The regex costs roughly 266 KB even trimmed. That is the real arithmetic of Rust binary size: a profile saves tens of kilobytes, a dependency decision saves hundreds. The two programs differ slightly in what they do, so read this as a scale, not as a benchmark of regex.
See what each crate drags in with the tree, and whether it appears more than once:
cargo tree --edges normal
cargo tree --duplicatesDuplicates matter because two versions of the same crate are compiled and linked twice. The cargo tree walkthrough shows how to find who pulled in the second copy.
Find out what is taking the space
Once the profile is tuned, the question changes from "which setting" to "which crate or function". Two tools answer it. cargo bloat lists the largest functions and, with --crates, the size per crate:
cargo install cargo-bloat
cargo bloat --release --crates -n 10
cargo bloat --release -n 15Read the crate view first. If one crate you did not expect is near the top, that is your target. The function view then tells you whether the cost is one huge function or a thousand small monomorphized copies, which is the signature of a generic function instantiated with many types. For that second case, cargo llvm-lines counts the IR lines each generic produces. The usual remedy is to move the non-generic part of a function into an inner non-generic function so it is compiled once:
use std::path::Path;
pub fn read_config<P: AsRef<Path>>(path: P) -> std::io::Result<String> {
// Only this thin wrapper is monomorphized per caller type.
fn inner(path: &Path) -> std::io::Result<String> {
std::fs::read_to_string(path)
}
inner(path.as_ref())
}The standard library uses this exact trick in std::fs::read_to_string. Without it, calls with a &str, a String and a PathBuf would each compile the whole body three times.
Avoid pulling in what you do not need
Four habits keep a binary small before you ever open a profile:
- Prefer a crate with feature flags and switch off the defaults you do not use, as with
regexabove. - Reach for
stdfirst.str::split_whitespaceandHashMapin the counter cost nothing extra; addingitertoolsfor one helper would. - Be wary of formatting. Each distinct
{:?}on a different type instantiates formatting code. A#[derive(Debug)]on every struct in a large program is measurable. - Choose
anyhoworBox<dyn Error>over many bespoke error enums with derivedDisplaywhen the binary is a small tool, trading precision of the error type for fewer generated impls.
Each is a judgment call. The error type one in particular trades away something real, so apply it to a CLI and not to a library other people match on.
What the small profile costs in build time
None of these settings are free, and the bill arrives on every build. On the same machine, an Apple silicon laptop, a clean cargo build --release of the word counter took 0.35 seconds with the default profile and 2.20 seconds with the tuned one. That is six times slower on a program with no dependencies. On a service with 300 crates the gap is minutes, and it recurs for every CI run, so the tuned profile belongs on the pipeline that produces the shipped artifact, not on the one that runs for each commit.
The split profile from the previous section handles it: ordinary cargo build --release stays fast, and the packaging job calls cargo build --profile release-small. Cache target/ between runs and only the first build pays the full cost.
Check what the linker kept
The size tool reports segments, which is a useful second opinion on stat. On macOS, for the unstripped default build and then for the build with a symbol table removed by hand:
size target/release/sz
strip -x target/release/sz -o /tmp/sz-stripped
stat -f%z /tmp/sz-stripped__TEXT __DATA __OBJC others dec hex
311296 16384 0 4295131136 4295458816 100078000
412128Reading it: about 311 KB of code and read-only data and 16 KB of writable data. The other 100 KB of the file is the symbol table and debug information, and a manual strip -x only partly removes it (412,128 bytes against the 376,944 that strip = true reached, because Cargo asks the linker to drop symbols as it links). On Linux the equivalent is size from binutils, or readelf -S to see each section. The number to watch is the code size, since the rest is overhead you can strip.
Put a size budget in CI
Binaries grow one dependency at a time, and nobody notices a 12 KB jump in a pull request. A budget turns the drift into a failing check. The simplest version is a shell step that compares the file against a limit:
#!/bin/sh
set -eu
limit=350000
file="target/release-small/sz"
size=$(wc -c < "$file")
echo "binary size: $size bytes (limit $limit)"
if [ "$size" -gt "$limit" ]; then
echo "over budget by $((size - limit)) bytes" >&2
exit 1
fiSet the limit about 10% above the current size, so a deliberate dependency has room and an accidental one trips the alarm. When it fires, run cargo bloat --release --crates on the branch and on main and diff the two lists. The crate that appeared or doubled is the answer, and the diff makes the review conversation concrete.
Trade-offs that bite later
Stripping everything makes production crashes hard to diagnose. If you ship stripped binaries, keep the matching unstripped build for every release, indexed by version or by the build ID, and upload debug files to whatever collects your crash reports. Without them a stack trace is a column of hexadecimal addresses. Rust makes this cheap: split-debuginfo writes the debug data to a separate file, so you ship the small binary and archive the big one.
[profile.release-small]
inherits = "release"
debug = "line-tables-only"
split-debuginfo = "packed"
strip = "debuginfo"Check the exact behavior on your target; split-debuginfo values differ by platform, and macOS writes a .dSYM bundle where Linux writes a .dwp file. The point is that "small to ship" and "readable when it crashes" are not in conflict as long as you plan where the debug data lives.
The step that needs nightly: rebuilding std
The reason opt-level barely moved anything is that std is prebuilt. The nightly-only -Zbuild-std flag compiles it from source with your profile, so opt-level = "z" and your own panic strategy apply to the library too. Embedded and WebAssembly projects use it routinely and report large savings, especially with -Zbuild-std-features=panic_immediate_abort, which drops all of the panic-message formatting machinery. We did not run it for this post, so there is no number here, but the command is:
rustup toolchain install nightly --component rust-src
cargo +nightly build --release \
-Z build-std=std,panic_abort \
-Z build-std-features=panic_immediate_abort \
--target "$(rustc -vV | sed -n 's/host: //p')"It needs --target explicitly. It also needs a nightly that matches the rust-src component, and a flag name that has changed across releases, so check the profile and size reference and your nightly's documentation before putting it in CI. Treat any unstable flag as a thing you pin and re-verify on every toolchain bump.
Do not compress it with UPX unless you must
UPX packs an executable and unpacks it into memory at start. A 300 KB binary may shrink to 120 KB on disk, but the process still needs the full image in RAM, startup is slower, and some antivirus products flag packed binaries. It also defeats the sharing of read-only pages between processes of the same program. On a container image or an embedded flash budget it can be right. On a laptop tool, the download time you save is usually less than the startup time you add on every run.
Where this advice stops
This is a recipe for command-line tools and services where binary size is a nice-to-have. If you ship to a microcontroller with 64 KB of flash, none of it is enough: you need #![no_std], a custom allocator or none, and build-std, which is a different discipline covered in the firmware size post. If you ship to a browser, WebAssembly has its own tool, wasm-opt, and its own limits.
It also does not hold when speed matters more than size. LTO and codegen-units = 1 usually help performance as well, but opt-level = "z" can slow hot loops, and panic = "abort" changes failure behavior. Benchmark the program you care about, not a word counter.
Next steps
Put the tuned profile in a named profile so day-to-day builds stay fast, and let CI build the small one:
[profile.release-small]
inherits = "release"
strip = true
lto = true
codegen-units = 1
panic = "abort"
opt-level = "z"cargo build --profile release-small
stat -f%z target/release-small/szRecord the size on every release, the way you record test counts. A binary that grows by 200 KB in one pull request is almost always one new dependency, and it is far easier to catch on the day it lands than six months later.
Do one last thing before you call it done: run the shrunken binary against real input. Smaller builds change what the compiler may inline and reorder, so a program that passed its tests in the default profile deserves one pass of the same tests with the release-small profile, using cargo test --profile release-small.



