195. Chain Iterator Adapters — Don't collect() Between Every Step

Every collect::<Vec<_>>() in the middle of a pipeline is a heap allocation and a full pass over your data. Adapters like map and filter are lazy and fuse together — chain them and the whole transformation runs in one pass with zero temporary Vecs.

A Vec between every step

It’s tempting to do one transformation at a time, binding each result to a variable. Every collect() allocates a throwaway Vec and walks the entire sequence before the next step even starts:

1
2
3
4
5
6
7
let nums = [1, 2, 3, 4, 5, 6];

let doubled: Vec<i32> = nums.iter().map(|&n| n * 2).collect();
let big: Vec<i32> = doubled.into_iter().filter(|&n| n % 4 == 0).collect();
let sum: i32 = big.iter().sum();

assert_eq!(sum, 24);

Two intermediate Vecs, two extra allocations, three separate passes — all to compute a single number.

One chain, one pass, no temporaries

The adapters compose directly. Nothing is materialized until the final consumer (sum) pulls values through, so there are no intermediate collections at all:

1
2
3
4
5
6
7
8
9
let nums = [1, 2, 3, 4, 5, 6];

let sum: i32 = nums
    .iter()
    .map(|&n| n * 2)        // 2, 4, 6, 8, 10, 12
    .filter(|&n| n % 4 == 0) // 4, 8, 12
    .sum();                  // 24

assert_eq!(sum, 24);

Each element flows through map then filter then into the sum, one at a time. No buffer is ever allocated.

Laziness means short-circuiting works

Because nothing runs until pulled, a chain only does the work it needs. Add a take(2) and the pipeline stops after producing two results — the elements past that point are never touched:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
use std::cell::Cell;

let visited = Cell::new(0);
let nums = [1, 2, 3, 4, 5, 6, 7, 8];

let first_two: Vec<i32> = nums
    .iter()
    .inspect(|_| visited.set(visited.get() + 1))
    .map(|&n| n * 10)
    .filter(|&n| n > 20)
    .take(2)
    .collect();

assert_eq!(first_two, [30, 40]);
assert_eq!(visited.get(), 4); // stopped early — never looked at 5..8

The intermediate-collect version can’t do this: collect() always drains the whole iterator, so it would have visited all eight elements before take ever saw one.

When you genuinely do need a Vec

The point isn’t “never collect” — it’s “don’t collect between steps.” Collect once, at the end, when you actually need an owned, reusable collection:

1
2
3
4
5
6
7
8
9
let words = ["fast", "lazy", "fused", "iter"];

let shouted: Vec<String> = words
    .iter()
    .filter(|w| w.len() == 4)
    .map(|w| w.to_uppercase())
    .collect();

assert_eq!(shouted, ["FAST", "LAZY", "ITER"]);

One collect, at the end, when the Vec is the actual result. Everything before it stays lazy and allocation-free.

194. Reuse One Buffer with .clear() — Allocate Once, Loop Many Times

with_capacity (bite 193) buys a buffer once instead of growing it repeatedly. But if you allocate a fresh String or Vec inside a loop, you throw that buffer away every iteration. .clear() resets the length to zero while keeping the capacity — so one allocation serves the whole loop.

A fresh allocation every iteration

It’s easy to declare the working buffer inside the loop. Each pass allocates a new heap buffer and drops it at the end of the iteration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
let lines = ["alpha", "beta", "gamma"];
let mut out = Vec::new();

for line in lines {
    let mut buf = String::new();   // new heap allocation, every iteration
    buf.push_str(line);
    buf.make_ascii_uppercase();
    out.push(buf.clone());
}

assert_eq!(out, ["ALPHA", "BETA", "GAMMA"]);

Three iterations, three allocate-then-free cycles for the scratch buffer. Scale that to a million lines and it’s a million wasted allocations.

.clear() keeps the capacity

Hoist the buffer out of the loop and clear() it at the top of each pass. clear() sets the length to 0 but leaves the allocated capacity in place, so after the first iteration the buffer is already big enough and never reallocates:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
let lines = ["alpha", "beta", "gamma"];
let mut out = Vec::new();
let mut buf = String::new();       // allocated once

for line in lines {
    buf.clear();                   // len -> 0, capacity untouched
    buf.push_str(line);
    buf.make_ascii_uppercase();
    out.push(buf.clone());
}

assert_eq!(out, ["ALPHA", "BETA", "GAMMA"]);

The contract is the whole point — clear drops the contents but not the buffer:

1
2
3
4
5
6
7
let mut s = String::with_capacity(64);
s.push_str("hello");
let cap = s.capacity();

s.clear();
assert_eq!(s.len(), 0);            // empty again
assert_eq!(s.capacity(), cap);     // ...but the buffer is still there

The read-into-a-reused-buffer pattern

This shows up constantly when reading input. BufRead::read_line appends to the buffer you give it, so the idiomatic loop clears one String each pass instead of allocating a new one per line:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
use std::io::BufRead;

let input = "12\n34\n56\n";
let mut reader = std::io::BufReader::new(input.as_bytes());

let mut line = String::new();      // one buffer for every line
let mut sum = 0i64;

loop {
    line.clear();                  // required — read_line appends
    let n = reader.read_line(&mut line).unwrap();
    if n == 0 {
        break;                     // 0 bytes read == EOF
    }
    sum += line.trim().parse::<i64>().unwrap();
}

assert_eq!(sum, 102);

The same trick works for any scratch Vecclear() it at the top of the loop and reuse the capacity for the next batch:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
let mut scratch: Vec<u8> = Vec::new();
let mut total = 0;

for chunk in [&[1u8, 2, 3][..], &[4, 5], &[6]] {
    scratch.clear();
    scratch.extend_from_slice(chunk);
    total += scratch.iter().map(|&b| b as u32).sum::<u32>();
}

assert_eq!(total, 21);

Reach for a fresh Vec/String only when you actually need to keep each result. When the buffer is just scratch space, allocate it once, clear() it, and let the loop run free.

193. Vec::with_capacity — Size Up Front, Skip the Realloc Churn

A Vec you push into one element at a time doesn’t grow one element at a time — it doubles, copying every existing item to a fresh allocation each time it outgrows its buffer. If you already know how many items are coming, Vec::with_capacity buys the whole buffer once.

The hidden cost of push

Vec::new() starts with zero capacity. As you push, it reallocates geometrically — roughly doubling each time — and every reallocation copies all existing elements into the new, larger buffer. Fill a vector with 1000 items and you pay for that copying around ten times over:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
let mut v = Vec::new();
let mut reallocs = 0;
let mut last_cap = v.capacity();

for i in 0..1000 {
    v.push(i);
    if v.capacity() != last_cap {
        reallocs += 1;        // the buffer just moved
        last_cap = v.capacity();
    }
}
// ~10 reallocations, each copying everything built so far
assert!(reallocs >= 8);

In a hot loop, that churn is pure waste: allocate, copy, free, allocate bigger, copy again.

Reserve the space once

If you know the final size, hand it to Vec::with_capacity. The buffer is allocated a single time, and push never has to move it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
let mut v = Vec::with_capacity(1000);
let mut reallocs = 0;
let mut last_cap = v.capacity();

for i in 0..1000 {
    v.push(i);
    if v.capacity() != last_cap {
        reallocs += 1;
        last_cap = v.capacity();
    }
}
assert_eq!(reallocs, 0);       // zero — the buffer never moved

Capacity is not length: with_capacity(1000) gives you room for 1000 items but the vector is still empty (len() == 0) until you push.

Already have a Vec? Use reserve

When the vector exists and you’re about to add a known number of elements, reserve grows the buffer ahead of time without touching the contents:

1
2
3
4
5
let mut v = vec![1, 2, 3];
v.reserve(100);               // ensure room for 100 *more*

assert!(v.capacity() >= 103);
assert_eq!(v.len(), 3);       // still 3 elements — only capacity changed

Use reserve before a batch of pushes; use reserve_exact when you want the buffer sized precisely, with no geometric slack.

collect often does this for you

Iterators expose a size_hint, so collecting from a sized iterator already reserves the right capacity — no manual call needed:

1
2
let squares: Vec<i32> = (0..1000).map(|x| x * x).collect();
assert_eq!(squares.len(), 1000);

The win is biggest exactly where it matters: tight loops building large vectors. If you can name the size, name it once and let push run free.

192. impl Into<String> — Take Owned or Borrowed Without an Extra Allocation

Bite 191 said: if you only read the argument, take &str. But what if you need to store it? Taking &str and calling .to_owned() always allocates — even when the caller handed you a String it was about to throw away. impl Into<String> fixes that.

The hidden re-allocation

When a function keeps the value, the “take &str” rule turns into a trap:

1
2
3
4
5
struct Label { text: String }

fn make_label(text: &str) -> Label {
    Label { text: text.to_owned() } // always allocates
}

A literal caller has to allocate eventually — fair enough. But look what happens when the caller already owns a String:

1
2
3
4
let owned = String::from("Status: OK");
let label = make_label(&owned);
// `owned` is copied into a brand-new allocation, then `owned` is dropped.
// We threw away a perfectly good String and allocated a second one.

The caller had an owned buffer it no longer needed, and we ignored it.

Accept anything that becomes a String

Take impl Into<String>. A String moves in with zero copying; a &str allocates exactly once — never more:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
struct Label { text: String }

fn make_label(text: impl Into<String>) -> Label {
    Label { text: text.into() }
}

// Literal: one allocation, unavoidable since we store it.
let a = make_label("Status: OK");

// Owned String: MOVED in. No copy, no second allocation.
let owned = String::from("Status: OK");
let b = make_label(owned);

assert_eq!(a.text, "Status: OK");
assert_eq!(b.text, "Status: OK");

Same call site for both, and the owned case is now free. The conversion happens lazily at the boundary, exactly once, and only when it must.

When you only read: impl AsRef

If you don’t store the value but still want to accept more than deref coercion allows (String, &str, Box<str>, Cow<str>, …), reach for impl AsRef<str>:

1
2
3
4
5
6
7
fn shout(text: impl AsRef<str>) -> String {
    text.as_ref().to_uppercase()
}

assert_eq!(shout("hi"), "HI");                       // &str
assert_eq!(shout(String::from("hi")), "HI");          // String
assert_eq!(shout(Box::<str>::from("hi")), "HI");      // Box<str>

as_ref() is a cheap borrow — no allocation — and the generic accepts every string-like type without forcing the caller to convert first.

The rule of thumb

If the function stores the string, take impl Into<String> so an owned argument moves in for free. If it only reads but you want maximum flexibility, take impl AsRef<str>. Plain &str (bite 191) is still the right default for simple read-only functions — these two just cover the cases it can’t.

191. Accept &str, Not String — Take the Most General Borrow

A function that takes String forces every caller holding a &str to allocate just to call you. Take &str instead — and &[T] over &Vec<T> — and deref coercion lets everyone in for free.

The over-specific signature

This function only ever reads its argument, yet it demands an owned String:

1
2
3
fn greeting(name: String) -> String {
    format!("Hello, {name}!")
}

Now a caller with a string literal — the most common case — has to allocate a whole String just to satisfy the type:

1
let g = greeting("Ferris".to_string()); // pointless allocation

Worse, a caller who only has a borrow (say, a field of someone else’s struct) is stuck: they must .clone() or .to_owned() before they can call you, even though you never keep the value.

Take the borrow

If the body only reads, take &str. Deref coercion means &String and string literals both coerce to &str automatically:

1
2
3
4
5
6
7
fn greeting(name: &str) -> String {
    format!("Hello, {name}!")
}

let owned = String::from("Ferris");
assert_eq!(greeting("Ferris"), "Hello, Ferris!"); // literal, no alloc
assert_eq!(greeting(&owned), "Hello, Ferris!");    // &String coerces to &str

Zero allocations at the call site, and every kind of caller just works.

Same rule for slices

The exact parallel exists for vectors. Taking &Vec<T> locks callers into owning a Vec; taking &[T] accepts a Vec, an array, or any slice:

1
2
3
4
5
6
7
8
9
fn total(nums: &[i32]) -> i32 {
    nums.iter().sum()
}

let v = vec![1, 2, 3];
let a = [4, 5, 6];
assert_eq!(total(&v), 6);       // &Vec<i32> coerces to &[i32]
assert_eq!(total(&a), 15);      // array coerces too
assert_eq!(total(&v[1..]), 5);  // a sub-slice — impossible with &Vec

&Vec<T> couldn’t accept that array or that sub-slice at all. &[T] is strictly more flexible and costs nothing.

The general principle

Borrow the least specific type that still does the job: &str over &String, &[T] over &Vec<T>, &Path over &PathBuf. Owned types in arguments are for when the function actually needs to store the value. If it only reads, hand it the borrow — the caller keeps their allocation, and your function works with more types for free.

#190 Jun 2026

190. Return Cow<str> — Allocate Only When You Actually Change Something

An escaping or normalizing function usually has nothing to do — the input is already clean. Returning String forces an allocation anyway. Return Cow<str> and the common path stays a borrow.

The wasteful version

A function that escapes HTML returns String, so every caller pays for an allocation — even the overwhelming majority whose input contains nothing to escape:

1
2
3
4
5
6
fn escape_html(input: &str) -> String {
    input
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

"hello world" has no special characters, yet replace still walks the string three times and hands back a fresh String. In a template renderer or a parser running this over thousands of fields, that’s thousands of pointless heap allocations.

Borrow on the fast path

Cow<str> lets one return type be either a borrow or an owned String. Check first; only allocate when there’s real work:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
use std::borrow::Cow;

fn escape_html(input: &str) -> Cow<str> {
    // Fast path: nothing to escape, hand back the original borrow.
    if !input.contains(['&', '<', '>']) {
        return Cow::Borrowed(input);
    }

    // Slow path: build the escaped String exactly once.
    let mut out = String::with_capacity(input.len());
    for c in input.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            _ => out.push(c),
        }
    }
    Cow::Owned(out)
}

The clean input never touches the heap; the dirty input allocates once instead of three times:

1
2
3
4
5
6
let clean = escape_html("hello world");
assert!(matches!(clean, Cow::Borrowed(_))); // zero allocation

let dirty = escape_html("a < b & c");
assert!(matches!(dirty, Cow::Owned(_)));
assert_eq!(dirty, "a &lt; b &amp; c");

Callers don’t notice

Cow<str> derefs to &str, so anything that reads the result just works — no .unwrap(), no matching:

1
2
3
4
5
fn render(field: &str) -> usize {
    escape_html(field).len() // Cow derefs to &str
}

assert_eq!(render("plain"), 5);

And when a caller genuinely needs ownership, .into_owned() allocates only if it’s still borrowed:

1
2
let owned: String = escape_html("safe").into_owned();
assert_eq!(owned, "safe");

The rule: any function that might return its input unchanged — escaping, trimming, normalizing, path canonicalization — should return Cow<str>, not String. The signature tells the caller “I’ll borrow when I can,” and the body only reaches for the heap on the path that earns it.

#189 Jun 2026

189. str::char_indices — Slice a String Without Panicking on Non-ASCII

chars().enumerate() hands you a character count, but &s[..] wants a byte offset. Mix them up and one accented letter blows your program apart.

Say you want everything from the underscore onward. The enumerate version looks right and works fine in tests full of ASCII:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
let s = "café_table"; // 'é' is two bytes in UTF-8

let idx = s
    .chars()
    .enumerate()
    .find(|(_, c)| *c == '_')
    .map(|(i, _)| i)
    .unwrap();

let rest = &s[idx..]; // idx == 4 (char count), but '_' starts at byte 5

idx is 4, the character position. Byte 4 lands in the middle of é, so the slice panics: byte index 4 is not a char boundary.

char_indices yields the real byte offset of each character, which is exactly what slicing expects:

1
2
3
4
5
6
7
8
let idx = s
    .char_indices()
    .find(|(_, c)| *c == '_')
    .map(|(i, _)| i)
    .unwrap();

assert_eq!(idx, 5);
assert_eq!(&s[idx..], "_table"); // no panic, correct slice

The pattern is (byte_offset, char) instead of enumerate’s (count, char). It’s also a DoubleEndedIterator, so next_back gives you the last character and where it begins:

1
2
let (last_off, last_ch) = s.char_indices().next_back().unwrap();
assert_eq!((last_off, last_ch), (10, 'e'));

Rule of thumb: the moment a character index touches &s[..], .split_at(), or any byte-indexed API, reach for char_indices — not enumerate.

188. LazyLock::from — Skip the Closure When You Already Have the Value

Sometimes your “lazy” value isn’t lazy at all — a test or a CLI flag hands it to you up front. Rust 1.96 stabilized From<T> for LazyLock<T>, so you can build an already-initialized lock straight from the value.

The old workaround was to wrap the known value in a move closure anyway:

1
2
// Pretend-lazy: the value sits captive until the first deref
let lock = LazyLock::new(move || url);

It compiles, but the lock reports “not initialized” until someone derefs it — even though you had the value the whole time. As of Rust 1.96, LazyLock::from (or .into()) builds the lock pre-initialized:

1
2
3
4
5
6
use std::sync::LazyLock;

let eager: LazyLock<u32> = LazyLock::from(42);

// Initialized immediately — no deref needed first
assert_eq!(LazyLock::get(&eager), Some(&42));

The practical win is mixing eager and lazy at runtime. From produces the default F = fn() -> T parameter — the same type a non-capturing closure coerces to — so both branches unify:

1
2
3
4
5
6
7
8
fn api_url(cli_override: Option<String>) -> LazyLock<String> {
    match cli_override {
        // Value already known: initialized, closure never exists
        Some(url) => LazyLock::from(url),
        // Computed on first use, as usual
        None => LazyLock::new(|| std::env::var("API_URL").unwrap()),
    }
}

Before 1.96 the Some arm needed LazyLock::new(move || url) — deferring initialization for no reason and making LazyLock::get lie to you in tests.

The single-threaded sibling From<T> for LazyCell<T> landed in the same release, so the trick works in both std::sync and std::cell flavors.

#187 Jun 2026

187. fmt::Write — Stop Allocating a Temp String Just to Append It

out.push_str(&format!("{name}: {score}")) builds a brand-new String, copies it into out, then throws it away — every single iteration. One use std::fmt::Write; and write! formats straight into your buffer instead.

The double-allocation habit

This pattern is everywhere, and it allocates a temporary String per call just to immediately copy and drop it:

1
2
3
4
5
6
7
let scores = [("ferris", 100), ("hermit", 42)];

let mut out = String::new();
for (name, score) in scores {
    out.push_str(&format!("{name}: {score}\n")); // temp String, copy, drop
}
assert_eq!(out, "ferris: 100\nhermit: 42\n");

Clippy even has a lint for it: format_push_string.

write! into the String directly

String implements std::fmt::Write, so the same write!/writeln! macros you use in Display impls work on it. The formatted output lands directly in the existing buffer — no intermediate allocation:

1
2
3
4
5
6
7
8
9
use std::fmt::Write; // bring the trait into scope

let scores = [("ferris", 100), ("hermit", 42)];

let mut out = String::new();
for (name, score) in scores {
    writeln!(out, "{name}: {score}").unwrap();
}
assert_eq!(out, "ferris: 100\nhermit: 42\n");

The .unwrap() looks scary but isn’t: write! returns fmt::Result because the trait allows failure, yet writing into a String can never fail — it just grows. let _ = writeln!(...) works too if you prefer.

Why it matters

The format! version allocates N temporary strings for N iterations. The write! version allocates only when out needs to grow — amortized, that’s a handful of reallocations total. In hot loops building large strings (reports, codegen, SQL), the difference shows up in profiles.

One gotcha: std::fmt::Write is for UTF-8 sinks (String); std::io::Write is for byte sinks (files, stdout). Same macro, different trait — if write!(out, ...) complains about no method named write_fmt, you imported the wrong one.

#186 Jun 2026

186. str::split_inclusive — Split a String and Keep the Separator With Each Chunk

"a\nb\n".split('\n') swallows every newline and hands you a phantom "" at the end. split_inclusive keeps each separator glued to the chunk it belongs to — no ghost element, and you can .concat() straight back to the original.

The split that loses information

The default split is destructive: the matched character is gone, and a trailing separator becomes an empty string.

1
2
3
4
5
let log = "INFO\nWARN\nERROR\n";

let parts: Vec<&str> = log.split('\n').collect();
assert_eq!(parts, ["INFO", "WARN", "ERROR", ""]);
//                                            ^^ phantom empty tail

That phantom empty string is the source of a hundred Stack Overflow questions. You usually paper over it with .filter(|s| !s.is_empty()) or trim_end() before splitting.

split_inclusive keeps the terminator

Each piece keeps the separator that ended it. The trailing newline isn’t an empty string — it’s the end of the last real chunk.

1
2
3
4
5
let log = "INFO\nWARN\nERROR\n";

let parts: Vec<&str> = log.split_inclusive('\n').collect();
assert_eq!(parts, ["INFO\n", "WARN\n", "ERROR\n"]);
assert_eq!(parts.concat(), log); // round-trips exactly

When you actually want this

Reformatting line by line without losing the trailing newline:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
let src = "fn main() {\n    println!(\"hi\");\n}\n";

let numbered: String = src
    .split_inclusive('\n')
    .enumerate()
    .map(|(i, line)| format!("{:>2} | {line}", i + 1))
    .collect();

assert_eq!(
    numbered,
    " 1 | fn main() {\n 2 |     println!(\"hi\");\n 3 | }\n"
);

No newline added, none lost — every \n is already where it should be.

Works on slices too

[T]::split_inclusive exists with the same shape, taking a predicate instead of a pattern. Useful for batching consecutive items up to a delimiter element.

1
2
3
let bytes = [1, 2, 0, 3, 0, 4];
let chunks: Vec<&[u8]> = bytes.split_inclusive(|&b| b == 0).collect();
assert_eq!(chunks, [&[1, 2, 0][..], &[3, 0][..], &[4][..]]);

Reach for split_inclusive whenever the separator is part of the data — line endings, statement terminators, record markers — not noise to throw away.