Performance and Avoiding Allocations
Rust is fast by default, but "zero-cost abstractions" means the abstractions are free, not that every line you write is. The most common performance mistakes in Rust aren't exotic, they're needless heap allocations and clones, scattered through code that works correctly and looks idiomatic. This tutorial covers spotting those, the borrowing-vs-owning decisions from Strings: Choosing the Right Type applied to performance, and the one rule that matters more than all the micro-optimizations: measure before you tune.
Measure First: Don't Guess Where Time Goes
The single most important performance habit is refusing to optimize on intuition. Compiled Rust's behavior, what the optimizer inlines, where cache misses happen, which allocation actually dominates, is routinely counterintuitive. Before changing anything, profile a release build (cargo build --release; debug builds are far slower and misleading) and benchmark the specific code path.
// a quick-and-dirty timing check; for real work use the `criterion` crate
let start = std::time::Instant::now();
let result = expensive_operation(&input);
eprintln!("took {:?}", start.elapsed());
For anything you care about, reach for criterion, it runs many iterations, accounts for warmup and noise, and reports statistically meaningful comparisons rather than a single timing that's mostly measurement error. The reason this comes first: most "optimizations" applied without measurement target code that wasn't the bottleneck, adding complexity for no real-world gain. Find the actual hot path, then optimize that.
The .clone() Reflex
Cloning to satisfy the borrow checker is the most common avoidable cost in beginner-to-intermediate Rust. A .clone() on a String or Vec is a fresh heap allocation plus a full copy of the contents, and it's easy to sprinkle them in just to make an error message go away. Often the real fix is to borrow instead.
// wasteful: clones the whole string just to read its length
fn describe(name: String) -> usize {
name.len()
}
let n = describe(my_name.clone()); // caller forced to clone to keep `my_name`
// better: borrow — no allocation, caller keeps ownership
fn describe(name: &str) -> usize {
name.len()
}
let n = describe(&my_name); // my_name still usable, nothing copied
The guideline from the strings tutorial generalizes to all performance-sensitive code: accept &T (or &str/&[T]) when you only need to read, and reserve owned parameters for when the function genuinely needs to keep the value. A function taking &str instead of String pushes the ownership decision to the caller, who often doesn't need to allocate at all. Not every clone is wrong, cloning a cheap Copy type or an Rc (just a refcount bump, from Smart Pointers Demystified) is fine, but a reflexive .clone() on a heap-owning type is worth a second look.
Reserve Capacity When You Know the Size
A Vec or String that grows past its capacity reallocates and copies everything to a larger buffer. Pushing N items into an empty Vec can trigger several reallocations as it doubles. When you know (even approximately) the final size, reserve it once up front, the same with_capacity point from Collections Deep Dive, here framed as a hot-loop optimization.
// reallocates repeatedly as it grows
let mut v = Vec::new();
for i in 0..10_000 {
v.push(i * 2);
}
// one allocation, no growth-copies
let mut v = Vec::with_capacity(10_000);
for i in 0..10_000 {
v.push(i * 2);
}
The same applies to String::with_capacity when building up text, and to HashMap::with_capacity. This is one of the highest-payoff, lowest-risk optimizations available: when the size is known, it's pure win with no downside.
Iterators Avoid Intermediate Allocations
Iterator chains are lazy, from Iterators Over Loops, nothing runs until consumed, and crucially, no intermediate collection is allocated between adapters. Calling .collect() partway through a chain forces an allocation that's often unnecessary.
// allocates a throwaway Vec just to iterate it again
let evens: Vec<i32> = numbers.iter().filter(|&&x| x % 2 == 0).cloned().collect();
let sum: i32 = evens.iter().sum();
// no intermediate Vec — one pass, zero extra allocation
let sum: i32 = numbers.iter().filter(|&&x| x % 2 == 0).sum();
The optimizer typically compiles a lazy iterator chain down to the same machine code as a hand-written loop, with no per-element overhead and no intermediate buffer. Reach for .collect() only when you actually need the materialized collection, not as a habitual step between transformations. When you do collect, collecting directly into the final type (e.g. collect::<String>() instead of building a Vec<char> first) skips a layer too.
Cow: Allocate Only When You Must
Cow<str> (Clone-on-Write), introduced in the strings tutorial, is the precise tool for "usually I can return a borrow, but sometimes I need to produce a new owned value." It lets the common, no-modification case stay allocation-free while still supporting the case that requires a fresh String.
use std::borrow::Cow;
fn sanitize(input: &str) -> Cow<str> {
if input.contains(' ') {
Cow::Owned(input.replace(' ', "_")) // allocates only when there's a space
} else {
Cow::Borrowed(input) // no allocation — just hands back the borrow
}
}
If most inputs need no change, sanitize allocates for almost none of them, the borrowed branch costs nothing, while callers that pass a string needing modification still get a correct owned result. This is the allocation-avoidance pattern for functions that conditionally transform their input, far better than unconditionally returning a String (which would allocate every time, even when nothing changed).
Key Takeaways
- Measure before optimizing: profile a
--releasebuild and benchmark withcriterion. Most un-measured optimizations target code that wasn't the bottleneck. - A
.clone()on a heap-owning type (String,Vec) is an allocation plus a full copy. Accept&T/&str/&[T]for read-only parameters and let the caller keep ownership instead of cloning to appease the borrow checker. - Use
with_capacityforVec/String/HashMapwhen the final size is known, it turns several growth-reallocations into one allocation, at zero risk. - Keep iterator chains lazy and avoid mid-chain
.collect(): the optimizer fuses adapters into a single allocation-free pass, often matching a hand-written loop. - Return
Cow<str>from functions that only sometimes need to produce a new owned value, so the common no-change path stays allocation-free. - Not every clone or allocation is a problem, cheap
Copy/Rcclones are fine. Optimize the measured hot path, not every line.