Skip to content

Rust Performance Basics

Rust has a reputation for speed, and it’s earned. But “Rust is fast” doesn’t mean every Rust program is fast. A slow algorithm is slow in any language, and a few habits can quietly cost you a lot.

The good news: most of the big wins are simple. Build in release mode. Measure before you change things. Don’t copy data you only need to read. Pick the right collection. This page walks through each one, with real measurements where they help. Think of it like tuning a bike: first check the tyres are pumped up (release mode), then worry about lighter parts.

Before you make anything faster, you need to know how slow it is. std::time::Instant is a stopwatch built into the standard library. Call Instant::now() before the work, and .elapsed() after:

use std::time::Instant;
fn main() {
let start = Instant::now();
// Make 5 million pseudo-random numbers with a simple xorshift.
let mut x: u64 = 12345;
let mut numbers = Vec::with_capacity(5_000_000);
for _ in 0..5_000_000 {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
numbers.push(x % 1_000_000);
}
numbers.sort();
let sum: u64 = numbers.iter().take(1000).sum();
println!("smallest 1000 add up to {sum}");
println!("took {:?}", start.elapsed());
}

The {:?} format prints a Duration in a readable way, like 250.271745ms. There’s no “Try it” button here on purpose: timings change on every run and every machine, so they can’t be checked against a fixed output. Run it on your own computer with cargo run instead.

This is the single biggest performance tip in Rust, and it’s just a flag.

Terminal window
cargo run # debug build: fast to compile, slow to run
cargo run --release # release build: slower to compile, much faster to run

A debug build skips most optimizations so it compiles quickly and is easy to debug. It also checks for integer overflow. A release build turns the optimizer on fully.

Here’s a sample measurement from running the program above three times each on the machine we used to write this page (a cloud server with an Intel Xeon CPU). Your numbers will be different — only the gap between them matters:

debug build: took 5.02s, 4.27s, 4.83s
release build: took 250ms, 297ms, 265ms

That’s roughly 15–20 times faster, from one flag. The gap depends a lot on the code — loops and iterators gain the most. But it’s never small enough to ignore.

So the rule is simple: never judge speed from a debug build. If someone says “Rust is slow” after timing cargo run, this is almost always why.

.clone() makes a full copy. For a String or Vec, that means a new heap allocation and copying every byte. Sometimes you need a copy — but often you only need to read the data, and a borrow is free.

The most common case is function parameters. If a function only reads a string, take &str, not String:

fn count_long_words(text: &str, min_len: usize) -> usize {
text.split_whitespace().filter(|w| w.len() >= min_len).count()
}
fn main() {
let owned = String::from("the quick brown fox jumps over the lazy dog");
// &String turns into &str automatically, no copy needed.
println!("{}", count_long_words(&owned, 5));
// A string literal is already a &str.
println!("{}", count_long_words("borrowing is cheap and cloning is not", 6));
// We never gave `owned` away, so it's still usable.
println!("{owned}");
}

Output:

3
2
the quick brown fox jumps over the lazy dog
▶ Try it Yourself

A &str parameter accepts both a String (borrowed with &) and a string literal. A String parameter would force callers to give up their string or clone it. The same idea works for lists: take &[T] instead of Vec<T>. See Borrowing and Slices for the details.

Watch for clones in loops, too. for name in names.clone() copies the whole list just to look at it. for name in &names borrows it for free.

When a Vec runs out of room, it allocates a bigger block and moves everything over. If you know the size up front, Vec::with_capacity reserves it in one go. This program counts how often each Vec had to grow while pushing 1000 numbers:

fn count_growths(mut v: Vec<u32>, n: u32) -> u32 {
let mut growths = 0;
let mut cap = v.capacity();
for i in 0..n {
v.push(i);
if v.capacity() != cap {
growths += 1;
cap = v.capacity();
}
}
growths
}
fn main() {
println!("Vec::new(): grew {} times", count_growths(Vec::new(), 1000));
println!("Vec::with_capacity(): grew {} times", count_growths(Vec::with_capacity(1000), 1000));
}

Output:

Vec::new(): grew 9 times
Vec::with_capacity(): grew 0 times
▶ Try it Yourself

Nine regrowths isn’t a disaster — the Vec doubles each time, so it stays cheap. But in a hot loop that builds millions of vectors, it adds up. (The exact growth pattern is an internal detail of the standard library and could change.)

Even easier: let collect() and join() do the work. They often know the final size and allocate once:

fn main() {
// collect() builds the Vec in one go.
let squares: Vec<u64> = (1..=5).map(|n| n * n).collect();
println!("{squares:?}");
// join() works out the total length first, then copies once.
let colors = ["red", "green", "blue"];
println!("{}", colors.join(", "));
}

Output:

[1, 4, 9, 16, 25]
red, green, blue
▶ Try it Yourself

You might worry that .iter().filter().map().sum() is slower than a plain loop, because it looks like a lot of function calls. It isn’t. In a release build, the compiler inlines all those small closures, and you get the same tight machine code as a hand-written loop. Rust calls this a zero-cost abstraction: you don’t pay for the nicer style.

fn sum_even_squares_loop(nums: &[u64]) -> u64 {
let mut total = 0;
for i in 0..nums.len() {
if nums[i] % 2 == 0 {
total += nums[i] * nums[i];
}
}
total
}
fn sum_even_squares_iter(nums: &[u64]) -> u64 {
nums.iter().filter(|&&n| n % 2 == 0).map(|&n| n * n).sum()
}
fn main() {
let nums: Vec<u64> = (1..=10).collect();
println!("loop: {}", sum_even_squares_loop(&nums));
println!("iter: {}", sum_even_squares_iter(&nums));
}

Output:

loop: 220
iter: 220
▶ Try it Yourself

The iterator version can even be a little faster. With nums[i], Rust checks that i is inside the slice on every access. The iterator already knows it can’t go out of bounds, so those checks disappear. Use whichever reads better — usually the iterator.

Choosing the right data structure often beats any amount of fine-tuning. A classic example: checking “is this item in my list?” many times.

  • Vec::contains looks at every element, one by one. Lookups get slower as the list grows.
  • HashSet::contains (and HashMap::get) jumps almost straight to the answer. Lookups stay fast no matter the size.
use std::collections::HashSet;
fn main() {
let banned: Vec<u32> = (0..20_000).map(|n| n * 3).collect();
let queries: Vec<u32> = (0..20_000).collect();
// Slow for big lists: each contains() scans the whole Vec.
let hits_vec = queries.iter().filter(|q| banned.contains(q)).count();
// Fast: build a HashSet once, then each lookup is quick.
let banned_set: HashSet<u32> = banned.iter().copied().collect();
let hits_set = queries.iter().filter(|q| banned_set.contains(q)).count();
println!("Vec found {hits_vec}, HashSet found {hits_set}");
}

Output:

Vec found 6667, HashSet found 6667
▶ Try it Yourself

Same answer, very different cost. When we added Instant timers around each part and ran a release build, this is a sample measurement from our machine:

Vec: 6667 hits in 27.97ms
HashSet: 6667 hits in 551.60µs

About 50 times faster — and that’s including the time to build the set. For a list of 10 items, though, a Vec is perfectly fine and can even win. Learn more on the HashSet and HashMap pages.

Programmers are famously bad at guessing where the time goes. So don’t guess: profile. A profiler watches your program run and tells you which functions take the most time. Usually it’s one or two hot spots, and the rest of your code doesn’t matter at all.

Two popular, free profilers that work with Rust:

Terminal window
# flamegraph: draws a picture of where time is spent (Linux, macOS)
cargo install flamegraph
cargo flamegraph
# samply: opens the results in the Firefox Profiler in your browser
cargo install samply
cargo build --release
samply record ./target/release/my_app

Profilers need debug symbols to show function names. Add this to Cargo.toml so release builds keep them (it doesn’t make the code slower, only the file bigger):

[profile.release]
debug = true

Timing something once with Instant is noisy. A benchmark runs the code many times and uses statistics to give a reliable number. Rust’s built-in #[bench] only works on nightly, so most people use the criterion crate. Set it up like this:

[dev-dependencies]
criterion = "0.8"
[[bench]]
name = "fib_bench"
harness = false

Put the code you want to test in your library (src/lib.rs), then write the benchmark in benches/fib_bench.rs:

use criterion::{criterion_group, criterion_main, Criterion};
use my_crate::fibonacci;
use std::hint::black_box;
fn bench_fib(c: &mut Criterion) {
c.bench_function("fibonacci 20", |b| b.iter(|| fibonacci(black_box(20))));
}
criterion_group!(benches, bench_fib);
criterion_main!(benches);

black_box stops the compiler from being clever and computing the answer ahead of time, which would make the benchmark meaningless. Run it with:

Terminal window
cargo bench

A sample result from our machine (criterion shows a low estimate, best estimate, and high estimate):

fibonacci 20 time: [24.417 µs 25.079 µs 25.737 µs]

Run it again after a change, and criterion tells you whether the code got faster or slower — and whether the difference is real or just noise.

The default release settings are good. When you need a bit more, these are the common knobs in Cargo.toml:

[profile.release]
opt-level = 3 # the default for release; "s" or "z" optimize for size instead
lto = true # link-time optimization: optimize across all crates (slower build)
codegen-units = 1 # one unit = more optimization chances, but slower to compile
panic = "abort" # skip unwinding code; smaller binary, but no catching panics

These usually give a few percent, not miracles, and they make builds slower. Try them one at a time, and measure each one. lto = "thin" is a popular middle ground that is faster to build than lto = true.

Timing a debug build. Already covered, but it’s the number one mistake: always measure with --release.

Cloning to make the compiler happy. Say a function takes a String, and you call it with a borrow:

fn shout(text: String) -> String {
text.to_uppercase()
}
fn main() {
let name = String::from("ferris");
let loud = shout(&name);
println!("{loud} {name}");
}

The compiler says:

error[E0308]: mismatched types
--> src/main.rs:7:22
|
7 | let loud = shout(&name);
| ----- ^^^^^ expected `String`, found `&String`
| |
| arguments to this function are incorrect

It’s tempting to write shout(name.clone()). That works, but it copies the string every call. The better fix is to change the parameter to text: &str — then shout(&name) compiles and nothing is copied.

println! in a hot loop. Each println! locks stdout and writes the line straight away. For a few lines that’s fine. For a million, it’s slow. Wrap stdout in a BufWriter so it writes in big chunks:

use std::io::{self, BufWriter, Write};
fn main() {
let mut out = BufWriter::new(io::stdout().lock());
for i in 1..=5 {
writeln!(out, "line {i}").unwrap();
}
}

Output:

line 1
line 2
line 3
line 4
line 5
▶ Try it Yourself

In our sample measurement, printing one million lines to a file took about 570–670ms with println! and about 40–60ms with BufWriter (release build).

  • Always measure speed with cargo run --release — debug builds can be many times slower.
  • Use Instant::now() and .elapsed() for quick timing, and criterion for reliable benchmarks.
  • Borrow (&str, &[T]) instead of cloning when you only need to read data.
  • Allocate once: Vec::with_capacity, collect(), and join() avoid repeated regrowth.
  • Iterators are zero-cost; pick a HashSet/HashMap for many lookups.
  • Profile first, then optimize the hot spots the profiler shows you.

Quick check

1. Your program feels slow when you run it with cargo run. What should you try first?

2. A function only reads a string. Which parameter type avoids needless copies?

3. You need to check whether 50,000 IDs are in a list of 50,000 banned IDs. What's the better choice for the banned list?

4. What does "iterators are zero-cost" mean?

5. Why does a criterion benchmark use black_box?

Score: 0 / 5