#173 May 2026

173. NonZeroU32 and Friends — Encode an Invariant and Shrink Option for Free

Option<u32> is 8 bytes. Option<NonZeroU32> is 4 bytes. Same information, half the size — and the compiler enforces “this can’t be zero” for you.

The problem

You have an ID, a port number, or a child-process exit code. It’s logically a u32, but 0 is meaningless or sentinel-only. So you reach for Option<u32>:

1
struct Handle { id: Option<u32> }

That’s now 8 bytes: 4 for the u32 and 4 more for the discriminant telling you which variant you’re in. Even worse, “no ID yet” and “ID is zero” are two distinct states the compiler can’t tell apart for you.

The fix: NonZero*

std::num::NonZeroU32 (and its siblings NonZeroI64, NonZeroUsize, etc.) is a u32 that’s guaranteed at the type level to never be zero. Constructors return Option so you can’t accidentally build an invalid one:

1
2
3
4
5
use std::num::NonZeroU32;

let port = NonZeroU32::new(8080).expect("non-zero");
assert_eq!(port.get(), 8080);
assert!(NonZeroU32::new(0).is_none());

Because zero is now “impossible,” the compiler reuses that bit pattern as the None discriminant — this is the niche optimization:

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

assert_eq!(size_of::<u32>(), 4);
assert_eq!(size_of::<Option<u32>>(), 8);          // discriminant + payload
assert_eq!(size_of::<NonZeroU32>(), 4);
assert_eq!(size_of::<Option<NonZeroU32>>(), 4);   // free!

Wrap it in a newtype

The real win is making invalid states unrepresentable. Stop passing raw u32 around and use a newtype:

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

#[derive(Copy, Clone, Debug)]
struct UserId(NonZeroU32);

impl UserId {
    fn new(raw: u32) -> Option<Self> {
        NonZeroU32::new(raw).map(UserId)
    }
    fn get(self) -> u32 {
        self.0.get()
    }
}

let alice = UserId::new(42).unwrap();
assert!(UserId::new(0).is_none());
assert_eq!(alice.get(), 42);

Now Option<UserId> is 4 bytes, Vec<Option<UserId>> is half the memory it would otherwise be, and “this u32 is a real user id, not a placeholder” is checked at construction, not at every call site.

When to reach for it

Any u32/u64/usize where zero is invalid: database row IDs, file descriptors, generation counters, capacity-like values, lengths that must be at least one. The generic NonZero<T> form also exists for cleaner code: NonZero<u32> reads the same and works in const contexts since Rust 1.79.

172. #[track_caller] — Point the Panic at the Caller, Not Your Helper

You wrap an assert in a helper to clean up your tests. Now every failure points at the helper’s source line instead of the test that called it. #[track_caller] fixes that with a single line of code.

The problem: panics blame the helper

Say you’ve factored out a custom check used across many tests:

1
2
3
4
5
6
7
8
fn assert_positive(x: i32) {
    assert!(x > 0, "expected positive, got {x}");
}

#[test]
fn it_works() {
    assert_positive(-3); // panics
}

The panic message looks like this:

1
2
thread 'it_works' panicked at src/lib.rs:2:5:
expected positive, got -3

src/lib.rs:2 is the line inside assert_positive. Every test that uses this helper points at the same spot. Useless.

The fix: one attribute

Put #[track_caller] on the helper and the reported location becomes whichever call site invoked it:

1
2
3
4
5
6
7
8
9
#[track_caller]
fn assert_positive(x: i32) {
    assert!(x > 0, "expected positive, got {x}");
}

#[test]
fn it_works() {
    assert_positive(-3); // panics, blames THIS line
}

Now the panic points at the test’s call, exactly like a built-in assert! does. That’s because assert!, unwrap, expect, Vec::index, and friends are all themselves #[track_caller].

How it works

The attribute makes the compiler thread the caller’s Location through the function. You can grab it explicitly with core::panic::Location::caller():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use std::panic::Location;

#[track_caller]
fn where_am_i() -> &'static Location<'static> {
    Location::caller()
}

fn main() {
    let loc = where_am_i();
    assert_eq!(loc.line(), 11); // the call site, not the fn body
}

The attribute propagates through wrappers — mark every layer between the panic and the public API, otherwise the chain breaks at the first un-annotated function and the location resets to that frame.

When to reach for it

Any time you wrap panic!, assert!, unwrap, or expect behind a helper that callers will treat as a primitive: test assertions, domain-specific unwraps, invariant checks. The cost is zero at runtime in optimized builds — the location is baked in at compile time.

171. assert_matches! — A Test Failure That Actually Tells You What Went Wrong

assert!(matches!(x, Foo::Bar)) panics with assertion failed: matches!(x, Foo::Bar) and zero hint about what x actually was. Rust 1.96 stabilises assert_matches!, which prints the offending value for you.

The old way leaves you guessing

The classic assert!(matches!(...)) combo has been in tests forever, but its failure message is useless:

1
2
3
4
5
6
#[derive(Debug)]
enum Status { Ok, Pending, Failed(u32) }

let s = Status::Failed(42);
assert!(matches!(s, Status::Ok));
// panic: assertion failed: matches!(s, Status :: Ok)

When this fires in CI, you get the pattern back but not the value. Was it Pending? Failed? With what code? You either rerun with dbg! or eyeball the test setup.

assert_matches! includes the value

assert_matches! lives in core (and std) as of 1.96. It checks the same way, but on failure it prints the Debug representation of what you handed it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
use std::assert_matches::assert_matches;

#[derive(Debug)]
enum Status { Ok, Pending, Failed(u32) }

let s = Status::Failed(42);
assert_matches!(s, Status::Ok);
// panic: assertion `left matches right` failed
//   left: Failed(42)
//  right: Status::Ok

Same one-liner, real diagnostic. No extra dbg!, no rerun.

Pattern guards still work

Because it’s a real pattern position, you get bindings and guards too — useful when you want “some variant with a value in a range”:

1
2
3
4
use std::assert_matches::assert_matches;

let n: Result<i32, &str> = Ok(7);
assert_matches!(n, Ok(x) if x > 0);

The guard is checked just like in a match arm, and the panic message still shows you the value if it fails.

Heads up: not in the prelude

Unlike assert! and assert_eq!, assert_matches! is not in the prelude — too many third-party crates (notably the assert_matches crate) already export the same name. Import it explicitly:

1
2
3
use std::assert_matches::assert_matches;
// or, in no_std:
// use core::assert_matches::assert_matches;

There’s also debug_assert_matches! for the same trick that compiles away in release builds.

Stabilised in Rust 1.96 (May 2026). Delete one third-party dependency from your dev-dependencies today.

#170 May 2026

170. Ordering::then_with — Chain Comparators for Multi-Key Sorts

Sorting by name, then by age, then by id ends in a nested if a == b { ... } ladder. Ordering::then_with flattens the whole thing into one expression.

The pain: a manual tie-break ladder grows ugly fast.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
items.sort_by(|a, b| {
    let ord = a.last.cmp(&b.last);
    if ord != std::cmp::Ordering::Equal {
        ord
    } else {
        let ord = a.first.cmp(&b.first);
        if ord != std::cmp::Ordering::Equal {
            ord
        } else {
            a.id.cmp(&b.id)
        }
    }
});

Ordering has two combinators built for exactly this:

  • then(other) — eager: returns self if it’s not Equal, otherwise other.
  • then_with(|| other) — lazy: only computes the next comparison if the previous was Equal.

Prefer then_with whenever the next cmp does real work (string comparison, derived keys), so you don’t pay for it on every pair.

1
2
3
4
5
items.sort_by(|a, b| {
    a.last.cmp(&b.last)
        .then_with(|| a.first.cmp(&b.first))
        .then_with(|| a.id.cmp(&b.id))
});

Reads top-to-bottom in priority order, no nesting, no early-return. Mixing ascending and descending is a one-character change — wrap a field in Reverse:

1
2
3
4
5
6
7
8
use std::cmp::Reverse;

// Last name ascending, then age DESCENDING, then id ascending.
items.sort_by(|a, b| {
    a.last.cmp(&b.last)
        .then_with(|| Reverse(a.age).cmp(&Reverse(b.age)))
        .then_with(|| a.id.cmp(&b.id))
});

The same pattern works in any Ord/PartialOrd impl — then_with is how a hand-written cmp stays readable.

#169 May 2026

169. File::create_new — Atomic 'Create Only If It Doesn't Exist'

You want to write a config file, a lockfile, or a “did we run yet” sentinel — but only if it isn’t already there. The if path.exists() { … } else { File::create(path) } pattern looks fine until two processes hit it at the same time. There’s a one-line fix sitting in std::fs.

The naive guard is a textbook TOCTOU race: between the moment you check existence and the moment you call create, another process can slip in and put a file there. You’ll then happily truncate their work.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
use std::fs::{self, File};
use std::path::Path;

fn write_once_racy(path: &Path) -> std::io::Result<File> {
    if path.exists() {
        return Err(std::io::Error::new(
            std::io::ErrorKind::AlreadyExists,
            "already there",
        ));
    }
    // Window of vulnerability: another process can create the file here.
    File::create(path) // truncates if it now exists
}

File::create_new (stable since 1.77) collapses both steps into a single syscall — O_CREAT | O_EXCL on Unix, CREATE_NEW on Windows — so the kernel decides the winner:

1
2
3
4
5
6
use std::fs::File;
use std::path::Path;

fn write_once(path: &Path) -> std::io::Result<File> {
    File::create_new(path)
}

If the file already exists, you get back an io::Error with ErrorKind::AlreadyExists and nothing on disk is touched. That’s the whole behaviour — and it’s the same whether one process or fifty are racing for the same path.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
use std::fs::{self, File};
use std::io::{ErrorKind, Write};

let path = std::env::temp_dir().join("rustbites-169.lock");
let _ = fs::remove_file(&path); // start clean

// First call wins and gets a writable handle.
let mut first = File::create_new(&path).expect("first create");
first.write_all(b"owner=me").unwrap();

// Second call fails — no truncation, no clobber.
let err = File::create_new(&path).unwrap_err();
assert_eq!(err.kind(), ErrorKind::AlreadyExists);

fs::remove_file(&path).unwrap();

For the equivalent guarantee on an existing handle you’d previously reach for OpenOptions::new().write(true).create_new(true).open(path) — that still works, and File::create_new is just the shorthand when you want the default “write, create-new, truncate-off” combo.

Use it for lockfiles, idempotent setup steps, “did we already write the manifest” checks, and anywhere the existence test and the create were a single logical step pretending to be two.

168. collect::<Result<Vec<_>, _>>() — Bail an Iterator on the First Error

You have a Vec<&str> of numbers to parse and you want to bail the moment one of them is malformed. The hand-rolled loop with ? works, but collect() does the same thing in one line — and most people never realize the trick is in the turbofish.

The pain point is everywhere: parse a row of CSV cells, deserialize a batch of records, convert a list of paths — any time you map a fallible function over an iterator and want the first failure to stop the show.

The naive version builds the Vec by hand and threads ? through a loop:

1
2
3
4
5
6
7
fn parse_all_loop(inputs: &[&str]) -> Result<Vec<i32>, std::num::ParseIntError> {
    let mut nums = Vec::with_capacity(inputs.len());
    for s in inputs {
        nums.push(s.parse::<i32>()?);
    }
    Ok(nums)
}

The same thing in one line, by asking collect to produce a Result<Vec<_>, _> instead of a Vec<Result<_, _>>:

1
2
3
fn parse_all(inputs: &[&str]) -> Result<Vec<i32>, std::num::ParseIntError> {
    inputs.iter().map(|s| s.parse::<i32>()).collect()
}

That works because Result<C, E> (where C: FromIterator<T>) implements FromIterator<Result<T, E>>: feed it an iterator of Results and it yields a single Result. The first Err short-circuits the rest of the iteration; if every item is Ok, you get Ok(collection) back.

1
2
3
4
5
6
7
8
let good = ["1", "2", "3"];
let bad  = ["1", "oops", "3"];

let ok: Result<Vec<i32>, _> = good.iter().map(|s| s.parse()).collect();
assert_eq!(ok.unwrap(), vec![1, 2, 3]);

let err: Result<Vec<i32>, _> = bad.iter().map(|s| s.parse()).collect();
assert!(err.is_err());

The same impl exists for Option, so collect::<Option<Vec<_>>>() bails on the first None:

1
2
3
4
5
let some_all: Option<Vec<u32>> = ["1", "2", "3"].iter().map(|s| s.parse().ok()).collect();
assert_eq!(some_all, Some(vec![1, 2, 3]));

let some_none: Option<Vec<u32>> = ["1", "no", "3"].iter().map(|s| s.parse().ok()).collect();
assert_eq!(some_none, None);

You’re not stuck with Vec either — any FromIterator target works, so Result<HashSet<_>, _> or Result<String, _> from a Chars iterator is just as valid.

Use it whenever you’d otherwise write a fold-the-error loop. The intent reads off the type signature, and the iterator stops cold at the first bad item instead of finishing the parse and then sifting through a Vec<Result<_, _>> afterwards.

#167 May 2026

167. Saturating<T> — Stop Calling .saturating_add() Everywhere

When every step in a loop or formula could overflow, calling .saturating_add() and .saturating_sub() on each one turns one line of math into a paragraph.

std::num::Saturating<T> is a tuple newtype wrapper that overloads the normal operators (+, -, *, +=, …) to use saturating semantics — operations clamp to the type’s MIN or MAX instead of wrapping or panicking. You write a + b, not a.saturating_add(b).

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

let hp: Saturating<u8> = Saturating(250);
let heal = Saturating(20u8);
let damage = Saturating(255u8);

// Pins at 255 instead of wrapping or panicking.
let healed = hp + heal;
assert_eq!(healed.0, 255);

// Pins at 0 instead of underflowing.
let dead = hp - damage;
assert_eq!(dead.0, 0);

The unwrapped equivalent works, but every operator turns into a method call:

1
2
3
4
5
let hp: u8 = 250;
let healed = hp.saturating_add(20);
let dead = hp.saturating_sub(255);
assert_eq!(healed, 255);
assert_eq!(dead, 0);

The wrapper really pays off inside a formula or an iterator chain, where you’d otherwise be wrapping each binary op:

1
2
3
4
5
use std::num::Saturating;

let damages = [Saturating(200u8), Saturating(100), Saturating(50)];
let total: Saturating<u8> = damages.iter().copied().sum(); // sticks at 255
assert_eq!(total.0, 255);

There’s a matching std::num::Wrapping<T> when you actually want cyclical math (hashes, CRCs, monotonic counters that should roll over), and both wrappers implement From, Default, Sum, and Product, so you can drop them into structs and iterator chains without ceremony.

Reach for Saturating<T> whenever a value has a logical floor or ceiling — health bars, progress percentages, retry budgets — and overflow should pin to the edge instead of panicking in debug or silently wrapping in release.

166. Entry::and_modify — Update If Present, Insert If Not, in One Chain

*map.entry(k).or_insert(0) += 1 is the classic Rust counter. The moment the “first time” branch needs to look different from the “next time” branch, that pattern stops fitting — and and_modify slots in.

HashMap::entry(k) returns an Entry enum pointing at the slot for k, occupied or vacant. The famous methods are or_insert(default) and or_insert_with(|| ...). Quieter but often nicer is and_modify: it runs a closure on the value when the key is already there, and does nothing when it isn’t. Chain it with or_insert and you get update if present, insert if not in one expression — with a single lookup.

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

let mut counts: HashMap<&str, u32> = HashMap::new();

for word in ["rust", "iron", "rust", "rust", "iron"] {
    counts
        .entry(word)
        .and_modify(|n| *n += 1)
        .or_insert(1);
}

assert_eq!(counts["rust"], 3);
assert_eq!(counts["iron"], 2);

For pure counters that’s a tie with *entry.or_insert(0) += 1. The shape really pays off when the two branches store different data. Imagine grouping events by user, where the first event records the user’s name and later events only bump a counter:

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

#[derive(Debug, PartialEq)]
struct UserStats { name: String, visits: u32 }

let events = [(1u32, "alice"), (2, "bob"), (1, "alice")];
let mut stats: HashMap<u32, UserStats> = HashMap::new();

for (id, name) in events {
    stats
        .entry(id)
        .and_modify(|s| s.visits += 1)
        .or_insert(UserStats { name: name.into(), visits: 1 });
}

assert_eq!(stats[&1], UserStats { name: "alice".into(), visits: 2 });
assert_eq!(stats[&2], UserStats { name: "bob".into(),   visits: 1 });

and_modify takes &mut V so you mutate in place; or_insert produces the initial V. and_modify also returns the Entry back, which is why the chain works — and which means you can stack several modifications before the final or_insert.

165. PhantomData<T> — The Zero-Sized Marker That Pretends to Own a T

You write a generic struct, never actually store a T in any field, and the compiler stops you with “parameter T is never used”. PhantomData<T> is the zero-cost lie that fixes it — a marker that occupies no bytes but tells the compiler “act as if I own a T.”

The problem shows up the moment you build a typed handle around something that isn’t a T:

1
2
3
4
// Won't compile: T isn't actually stored anywhere.
struct TypedId<T> {
    raw: u64,
}

rustc rejects this because an unused type parameter is almost always a bug — variance, drop checking, and Send/Sync all depend on what a struct claims to own. std::marker::PhantomData<T> is the escape hatch: a zero-sized struct that pretends the type parameter is used:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use std::marker::PhantomData;

struct TypedId<T> {
    raw: u64,
    _marker: PhantomData<T>,
}

impl<T> TypedId<T> {
    fn new(raw: u64) -> Self {
        Self { raw, _marker: PhantomData }
    }
}

struct User;
struct Order;

let u: TypedId<User>  = TypedId::new(1);
let o: TypedId<Order> = TypedId::new(1);

// Same raw value, different types — the compiler refuses to mix them.
// let _: TypedId<User> = o; // error: expected TypedId<User>, found TypedId<Order>

assert_eq!(std::mem::size_of::<TypedId<User>>(), 8); // still just the u64

The _marker field disappears at runtime — size_of::<TypedId<User>>() is exactly size_of::<u64>(). But at compile time, TypedId<User> and TypedId<Order> are distinct types you can’t accidentally swap.

The same pattern fixes lifetimes too. FFI wrappers borrow from a buffer they don’t physically point into:

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

struct CursorHandle<'a> {
    raw_ptr: *const u8,
    _borrow: PhantomData<&'a [u8]>,
}

impl<'a> CursorHandle<'a> {
    fn new(buf: &'a [u8]) -> Self {
        Self { raw_ptr: buf.as_ptr(), _borrow: PhantomData }
    }
}

let buf = vec![1u8, 2, 3];
let cursor = CursorHandle::new(&buf);
// drop(buf); // compile error — cursor still borrows it, thanks to PhantomData
let _ = cursor;

Without the PhantomData<&'a [u8]>, the 'a would be unused and the compiler wouldn’t enforce that buf outlives cursor. With it, the borrow checker treats CursorHandle<'a> as if it held a real &'a [u8].

Three flavors of PhantomData you’ll see in the wild — pick by what you want the compiler to believe:

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

// 1. Owns a T (covariant, drops a T): PhantomData<T>
struct Owns<T>(PhantomData<T>);

// 2. Borrows a T (no drop): PhantomData<&'a T>
struct Borrows<'a, T>(PhantomData<&'a T>);

// 3. Neither Send nor Sync: PhantomData<*const ()>
struct NotThreadSafe(PhantomData<*const ()>);

fn assert_send<T: Send>() {}
// assert_send::<NotThreadSafe>(); // would fail — raw ptr makes it !Send

assert_eq!(std::mem::size_of::<Owns<u64>>(), 0);
assert_eq!(std::mem::size_of::<Borrows<'_, u64>>(), 0);
assert_eq!(std::mem::size_of::<NotThreadSafe>(), 0);

That last one is the cheap way to opt a type out of Send/Sync without unsafeRc<T> uses exactly this trick internally to stay single-threaded.

PhantomData is the bookkeeping behind almost every wrapper type you’ve used. Cell, Cow, Pin, Rc, and NonNull all carry one — it’s how they tell the compiler what they conceptually own without paying for it at runtime.

164. Pin projection — How to actually use the fields behind Pin<&mut Self>

The moment you hand-roll Future::poll, you have a Pin<&mut Self> and a question Rust won’t answer for you: how do I touch my fields? self.inner doesn’t compile, &mut self.inner is what Pin exists to prevent, and the answer — pin projection — is one of those idioms everyone reinvents until they reach for pin-project-lite.

bite-162 covered what Pin<P> is and why async futures need it. This one is about the very next thing you trip over: actually polling the inner future from your own poll method.

The problem

A wrapper that polls an inner future and counts how many times it was polled:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct Logged<F> {
    inner: F,
    polls: u32,
}

impl<F: Future> Future for Logged<F> {
    type Output = F::Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        self.inner.poll(cx) // ERROR: can't borrow through Pin<&mut Self>
    }
}

Pin<&mut Self> deliberately won’t deref-mut into &mut Self — that would hand back the exact &mut you need to mem::swap the whole struct out from under whatever pinned it. So self.inner is a non-starter. You have to project: turn a Pin<&mut Self> into a Pin<&mut F> pointing at the inner field.

Manual projection with unsafe

The raw tools are Pin::get_unchecked_mut and Pin::new_unchecked. You take &mut Self out of the pin (unsafe — you’re promising not to move the whole value), borrow disjoint fields, then re-pin the ones that need it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

struct Logged<F> {
    inner: F,
    polls: u32,
}

impl<F: Future> Future for Logged<F> {
    type Output = F::Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        // SAFETY: we promise not to move `self`. `inner` is treated as
        // structurally pinned; `polls` is treated as freely movable.
        let this = unsafe { self.get_unchecked_mut() };
        this.polls += 1;
        let inner = unsafe { Pin::new_unchecked(&mut this.inner) };
        inner.poll(cx)
    }
}

Two unsafe blocks and an invariant you have to remember everywhere else in the file: if some other method ever does mem::replace(&mut this.inner, _), you’ve broken the pin contract and quietly created UB. The compiler will not catch it.

The clean answer: pin-project-lite

pin-project-lite mechanically derives the safe projection. Mark each structurally-pinned field with #[pin]:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use pin_project_lite::pin_project;

pin_project! {
    struct Logged<F> {
        #[pin]
        inner: F,
        polls: u32,
    }
}

impl<F: Future> Future for Logged<F> {
    type Output = F::Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        let this = self.project();
        *this.polls += 1;
        this.inner.poll(cx)
    }
}

self.project() returns a generated struct where every #[pin] field is a Pin<&mut Field> and every other field is a plain &mut Field. No unsafe, no projection mistakes, no chance of accidentally mem::replace-ing a pinned field — the macro generates the accessors so the wrong move never compiles. This is the pattern tokio, hyper, futures, and effectively every library implementing custom futures lives on.

Structural vs non-structural — the choice you’re making

Marking a field #[pin] locks in three guarantees:

  • You will never move out of it once Self is pinned (no mem::replace, no mem::swap).
  • Its Drop impl runs while the field is still pinned.
  • Accessors hand you Pin<&mut Field>, not &mut Field.

Unmarked fields go the other way: you treat them as freely movable. Pick wrong — pin one structurally and then mem::swap it elsewhere — and you’ve quietly invalidated whatever pointers something else handed out into that field.

Rule of thumb: if a field is itself a future, or any !Unpin type that needs to be polled in place, mark it #[pin]. Counters, flags, owned Strings — leave them unmarked.