#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.

#185 Jun 2026

185. Range<NonZeroU32> — Iterate NonZero Integers Without Re-Wrapping Every Step

NonZeroU32 keeps Option<Id> at 4 bytes — but until Rust 1.96 you couldn’t iterate lo..hi over them. You’d drop back to u32 and re-wrap every step.

NonZeroU32 and friends are great for indices and IDs because Option<NonZeroU32> fits the niche and stays 4 bytes wide. The catch: Range<NonZeroU32> wasn’t an iterator. The moment you wanted to walk a range of IDs, you fell back to plain u32 and unwrapped your way back in:

1
2
3
4
5
6
7
8
9
use std::num::NonZeroU32;

let lo = NonZeroU32::new(1).unwrap();
let hi = NonZeroU32::new(5).unwrap();

// Pre-1.96: lift, iterate plain ints, re-wrap each step.
let ids: Vec<NonZeroU32> = (lo.get()..hi.get())
    .map(|n| NonZeroU32::new(n).unwrap())
    .collect();

Three problems: the Range drops the invariant, every step pays for an unwrap, and a future refactor that changes the bound type silently swaps your iterator out from under you.

Rust 1.96 stabilized Step for NonZero integers (PR #127534). Now the range itself is an iterator that yields NonZeroU32:

1
2
3
4
5
6
7
8
use std::num::NonZeroU32;

let lo = NonZeroU32::new(1).unwrap();
let hi = NonZeroU32::new(5).unwrap();

let ids: Vec<NonZeroU32> = (lo..hi).collect();
assert_eq!(ids.len(), 4);
assert_eq!(ids[0].get(), 1);

It works for the inclusive form too, so you can sweep the whole representable range without overflow gymnastics:

1
2
3
4
5
6
use std::num::NonZeroU8;

let total: u32 = (NonZeroU8::MIN..=NonZeroU8::MAX)
    .map(|n| n.get() as u32)
    .sum();
assert_eq!(total, 32_640); // 1 + 2 + ... + 255

If you keep your IDs in a NonZeroU32 newtype to shrink Option, the iteration story now matches: the range yields the right type the whole way through, no per-step unwrap, no invariant laundering through u32.

#184 Jun 2026

184. Option::take — The Ergonomic Sibling of mem::take, Just for Option

You’ve got an Option<T> behind &mut self and you need the T out. mem::take works, but the field is already an Option.take() reads better and does the same job.

This morning’s bite on std::mem::take covered the general move: swap in T::default(), hand back the original. For Option<T>, the default is None — and the standard library exposes that exact operation as Option::take, so the call site stops looking like memory plumbing:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct Connection {
    handle: Option<Socket>,
}

impl Connection {
    fn disconnect(&mut self) -> Option<Socket> {
        // std::mem::take(&mut self.handle)  // works, reads like low-level glue
        self.handle.take()                  // same thing, reads like English
    }
}
# struct Socket;

Internally Option::take is one line: mem::replace(self, None). The win is purely about the call site.

The pattern earns its keep in Drop, where you need to consume an owned resource by value but only have &mut self:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct Worker {
    join: Option<std::thread::JoinHandle<()>>,
}

impl Drop for Worker {
    fn drop(&mut self) {
        if let Some(handle) = self.join.take() {
            let _ = handle.join();   // join() consumes the handle by value
        }
    }
}

Without .take(), you can’t move self.join out (you only have &mut self), and JoinHandle::join takes self by value — so you’re stuck. take() swaps None in and gives you the owned JoinHandle to consume.

It also kills the classic “transfer once” pattern in builders and state machines:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct Pending {
    payload: Option<String>,
}

impl Pending {
    fn send(&mut self) -> Option<String> {
        // Hand the payload to the caller; we no longer own it.
        // Subsequent calls return None.
        self.payload.take()
    }
}

Reach for Option::take whenever the field is already Option<T>. Reach for std::mem::take when the field is some other T: Default and you want the empty version left behind.

#183 Jun 2026

183. std::mem::take — Move Out of &mut self Without the Clone

You need the Vec out of &mut self, the borrow checker says no, so you .clone() it. Don’t. mem::take swaps in the default and hands you the original — zero allocation.

The borrow checker won’t let you move a field out of &mut self, because that would leave self half-initialized. The usual workarounds are ugly: clone the whole thing, or refactor the API to take self by value.

std::mem::take does the right thing in one line. It replaces the field with T::default() and returns the old value. For collections, Default is empty — so there’s no allocation, just a pointer swap.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use std::mem;

struct Buffer {
    items: Vec<String>,
}

impl Buffer {
    fn drain_all(&mut self) -> Vec<String> {
        // self.items                 // ❌ cannot move out of borrowed content
        // self.items.clone()         // ❌ allocates + copies every String
        mem::take(&mut self.items)    // ✅ swaps in empty Vec, returns the real one
    }
}

Where it really earns its keep is state-machine transitions, where you need to consume the data inside the current variant before swapping the variant:

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

enum Stream {
    Buffering(Vec<u8>),
    Done,
}

impl Stream {
    fn finish(&mut self) -> Vec<u8> {
        if let Stream::Buffering(buf) = self {
            let bytes = mem::take(buf);   // pull the Vec out, leave [] behind
            *self = Stream::Done;          // now safe to overwrite self
            return bytes;
        }
        Vec::new()
    }
}

Without mem::take, that pattern usually devolves into a mem::replace(self, Stream::Done) and a match on the returned value. mem::take is shorter and reads top-to-bottom.

It works for any T: DefaultString, HashMap, Option, Box<[T]>, your own structs that derive Default. If Default isn’t free for your type, reach for mem::replace and pass the sentinel you actually want.

#182 Jun 2026

182. Path::with_extension — Swap a File Extension Without Slicing Strings

You have report.txt and want report.md. Reaching for replace(".txt", ".md") or a rfind('.')? Stop — Path::with_extension returns a fresh PathBuf with the extension swapped, and it gets every edge case right.

The string-slicing trap

The naïve fix looks reasonable until you read it carefully:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fn change_ext_bad(name: &str, ext: &str) -> String {
    match name.rfind('.') {
        Some(i) => format!("{}.{}", &name[..i], ext),
        None    => format!("{}.{}", name, ext),
    }
}

assert_eq!(change_ext_bad("report.txt", "md"), "report.md");
// But...
assert_eq!(change_ext_bad("./.bashrc", "bak"), "./.bak"); // ate the dotfile name

That second case is the bug: ./.bashrc has no extension — the leading dot is part of the name. Manual rfind('.') doesn’t know that.

The fix: Path::with_extension

1
2
3
4
5
6
use std::path::{Path, PathBuf};

let p = Path::new("reports/q1.txt");
let renamed: PathBuf = p.with_extension("md");

assert_eq!(renamed, PathBuf::from("reports/q1.md"));

It returns a new PathBuf — original path untouched — and stays in OsStr land the whole way through, so non-UTF-8 paths survive intact.

Dotfiles are handled the way you’d want:

1
2
3
4
use std::path::{Path, PathBuf};

assert_eq!(Path::new(".bashrc").with_extension("bak"),
           PathBuf::from(".bashrc.bak"));

No extension to start? It adds one instead of failing:

1
2
3
4
use std::path::{Path, PathBuf};

assert_eq!(Path::new("Makefile").with_extension("bak"),
           PathBuf::from("Makefile.bak"));

Pass "" to strip the extension

The same method, with an empty string, removes the extension entirely — no separate without_extension API needed:

1
2
3
4
use std::path::{Path, PathBuf};

let src = Path::new("build/main.o");
assert_eq!(src.with_extension(""), PathBuf::from("build/main"));

Common pattern in build scripts: derive an output path from an input path.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
use std::path::{Path, PathBuf};

fn object_for(src: &Path) -> PathBuf {
    src.with_extension("o")
}

assert_eq!(object_for(Path::new("src/main.rs")),
           PathBuf::from("src/main.o"));
assert_eq!(object_for(Path::new("src/lib.rs")),
           PathBuf::from("src/lib.o"));

Only the last extension changes

with_extension replaces from the last dot — same rule as file_stem. For archive.tar.gz, that means only .gz gets swapped:

1
2
3
4
use std::path::{Path, PathBuf};

assert_eq!(Path::new("archive.tar.gz").with_extension("zst"),
           PathBuf::from("archive.tar.zst"));

That’s almost always what you want for compression tools. If you need to strip the whole .tar.gz and start over, call with_extension("") twice — or reach for file_prefix (see bite 116).

set_extension if you already own the PathBuf

The mutating sibling lives on PathBuf and avoids the allocation when you already own the path:

1
2
3
4
5
use std::path::PathBuf;

let mut p = PathBuf::from("notes/draft.md");
p.set_extension("html");
assert_eq!(p, PathBuf::from("notes/draft.html"));

Returns booltrue if the extension was set, false if the path had no file name to attach one to. Most callers ignore it.

Reach for with_extension (or set_extension) any time you’d otherwise write a rfind('.') or a replace(".old", ".new"). It’s been stable since Rust 1.0 — there’s no excuse left.

#181 Jun 2026

181. Option::get_or_insert_with — Lazy Default That Returns &mut

You have an Option<Vec<T>> field and want to push to it. If it’s None, allocate first; if it’s Some, just push. get_or_insert_with does both in one call — and hands you back a &mut so you can use it on the same line.

The dance you don’t have to do

The naïve version checks, assigns, then unwraps:

1
2
3
4
5
6
7
8
let mut tags: Option<Vec<String>> = None;

if tags.is_none() {
    tags = Some(Vec::new());
}
tags.as_mut().unwrap().push("verbose".into());

assert_eq!(tags, Some(vec!["verbose".to_string()]));

Three lines, an unwrap, and you re-borrow the Option twice. get_or_insert_with collapses it:

1
2
3
4
5
let mut tags: Option<Vec<String>> = None;

tags.get_or_insert_with(Vec::new).push("clean".into());

assert_eq!(tags, Some(vec!["clean".to_string()]));

It initializes the Option to Some(f()) if and only if it was None, and returns &mut T to the inner value either way. No unwrap, no double-check.

The closure only runs when needed

That’s the whole point of the _with suffix: the default is lazy. If the value’s already there, your closure never fires, which matters when the default is expensive or has side effects:

1
2
3
4
5
6
7
8
let mut cache: Option<Vec<u8>> = Some(vec![1, 2, 3]);

let buf = cache.get_or_insert_with(|| {
    panic!("would allocate a huge buffer");
});
buf.push(4);

assert_eq!(cache, Some(vec![1, 2, 3, 4]));

If your default is cheap (a String::new(), a 0u32), use the eager sibling get_or_insert and skip the closure:

1
2
3
let mut log: Option<String> = None;
log.get_or_insert(String::new()).push_str("hi");
assert_eq!(log.as_deref(), Some("hi"));

Why the &mut return matters

get_or_insert_with returns &mut T, not T or Option<T>. That lets you keep chaining — push, mutate, hand to another function — without ever re-borrowing the Option:

1
2
3
4
5
6
let mut counters: Option<Vec<u32>> = None;

counters.get_or_insert_with(Vec::new).extend([1, 2, 3]);
counters.get_or_insert_with(Vec::new).push(4);

assert_eq!(counters, Some(vec![1, 2, 3, 4]));

The classic case is builders and config structs with Option<Vec<_>> fields that should only allocate when the caller actually adds something. One line per add, no upfront Some(Vec::new()), no unwrap.

Stable since Rust 1.20.

#180 Jun 2026

180. Option::unzip — Split an Optional Pair Into a Pair of Options

When you have an Option<(A, B)> but the rest of your code wants (Option<A>, Option<B>), the obvious move is two map calls. Option::unzip does both halves in a single call.

The two-map dance

Say a parser returns Option<(&str, i32)> — a name and an age, or nothing. Downstream you want them as separate optional columns:

1
2
3
4
5
6
7
let parsed: Option<(&str, i32)> = Some(("ada", 36));

let name = parsed.map(|(n, _)| n);
let age  = parsed.map(|(_, a)| a);

assert_eq!(name, Some("ada"));
assert_eq!(age,  Some(36));

That’s two passes over the same Option, two closures that throw away half their input, and an easy place to typo n for a. Option::unzip collapses it:

1
2
3
4
5
6
let parsed: Option<(&str, i32)> = Some(("ada", 36));

let (name, age) = parsed.unzip();

assert_eq!(name, Some("ada"));
assert_eq!(age,  Some(36));

Some((a, b)) becomes (Some(a), Some(b)). No closures, no destructuring noise.

The None case is the whole point

unzip shines on the None branch. None becomes (None, None) — both sides empty, no special-casing:

1
2
3
4
5
let missing: Option<(&str, i32)> = None;
let (name, age) = missing.unzip();

assert_eq!(name, None);
assert_eq!(age,  None);

Compare with the manual version: you’d still write two maps and rely on each one to short-circuit. Same behavior, more lines, more chances to drift apart when someone edits one branch and forgets the other.

Round-trip with zip

Option::zip is the inverse — it builds Option<(A, B)> from (Option<A>, Option<B>), returning Some only if both are Some:

1
2
3
4
5
6
7
8
9
let name: Option<&str> = Some("ada");
let age: Option<i32>   = Some(36);

let joined = name.zip(age);
assert_eq!(joined, Some(("ada", 36)));

let (n, a) = joined.unzip();
assert_eq!(n, Some("ada"));
assert_eq!(a, Some(36));

One mental model: Option<(A, B)> and (Option<A>, Option<B>) are almost the same shape, and these two methods let you flip between them without match.

When to reach for it

Any time a single optional value naturally carries two parts that the rest of the code wants to handle independently — coordinates, key/value pairs, name/age, parsed/raw. If you find yourself writing opt.map(|(x, _)| x) and opt.map(|(_, y)| y) back-to-back, that’s the signal.

Stable since Rust 1.66.