I’d been writing async Rust for about three months before I had to actually understand Pin. Up until then I’d followed the rule of “if the compiler complains about Pin, add Box::pin,” which worked well enough to get my code compiling. But eventually I wrote a library where that wasn’t enough, and I had to sit down with an actual paper on self-referential structs to understand what was going on.

The problem Pin solves is, at its core, that async fn desugars into a state machine, and the state machine can have pointers into itself. Consider:

async fn echo(stream: TcpStream) -> io::Result<()> {
    let mut buf = [0u8; 1024];
    let n = stream.read(&mut buf).await?;
    stream.write(&buf[..n]).await
}

When this is compiled, the async fn becomes a state machine that looks conceptually like:

enum EchoState {
    Start { stream: TcpStream },
    AtReadAwait {
        stream: TcpStream,
        buf: [u8; 1024],
        read_fut: /* the future returned by stream.read */,
    },
    AtWriteAwait {
        stream: TcpStream,
        buf: [u8; 1024],
        n: usize,
        write_fut: /* the future returned by stream.write */,
    },
    Done,
}

Look at AtReadAwait. The read_fut is a future that borrows from stream and buf. That borrow is a pointer, and the pointer is into the same struct. If you move the struct in memory, the pointer still points to the old location. Boom.

This is a self-referential struct. Most Rust code goes to great lengths to avoid self-referential structs because of exactly this issue, but async state machines can’t — the compiler generates them. So the async machinery needs a way to say “this future cannot be moved in memory once it’s been started.”

That’s what Pin<P> does. Pin<P> wraps a pointer P and promises that the pointee will not be moved in memory. It’s a type-system construct; at runtime, Pin<&mut T> is just &mut T. The compile-time magic is in the API: you can’t get &mut T out of a Pin<&mut T> without unsafe unless T: Unpin.

Unpin is the escape hatch. It’s an auto-trait meaning “even though I’m Pinned, I promise I can be safely moved.” Almost everything in Rust is Unpin: integers, strings, Vec, most custom structs. What’s NOT Unpin is async state machines (because they might be self-referential) and explicitly-opted-out types like PhantomPinned.

For user code that doesn’t have self-references, all of this is invisible. You write async fn and .await and it just works. You never have to think about Pin.

Where you do have to think about it is when you’re:

  1. Writing a library that takes a future as input
  2. Implementing Future manually
  3. Building something like a Stream combinator

Here’s a manual Future impl:

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

struct Delay {
    when: std::time::Instant,
}

impl Future for Delay {
    type Output = ();

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
        if std::time::Instant::now() >= self.when {
            Poll::Ready(())
        } else {
            // register a waker for when time advances
            cx.waker().wake_by_ref();
            Poll::Pending
        }
    }
}

Notice the self: Pin<&mut Self> receiver. That’s the “I promise this future won’t be moved” guarantee. Because Delay has no self-referential fields, it’s trivially Unpin, and you can access self.when directly. If you had a field that was itself a future, you’d need pin-project or pin-project-lite to safely get a Pin<&mut Field> out of Pin<&mut Self>.

pin-project is the everyday tool. It generates accessor methods that preserve pinning for fields that need it, and don’t preserve pinning for fields that are Unpin:

use pin_project_lite::pin_project;

pin_project! {
    struct Combined<F1, F2> {
        #[pin]
        first: F1,
        #[pin]
        second: F2,
        count: usize, // not pinned, so it's fine to freely access
    }
}

impl<F1, F2> Combined<F1, F2>
where
    F1: Future<Output = ()>,
    F2: Future<Output = ()>,
{
    fn poll_first(self: Pin<&mut Self>, cx: &mut Context) {
        let this = self.project();
        let _ = this.first.poll(cx);
        *this.count += 1; // normal &mut usize
    }
}

The project() method generates a struct where pinned fields are Pin<&mut F> and unpinned fields are &mut V. This is the API I use to write any non-trivial future.

The paper I ended up reading was Aaron Turon’s original async/await design doc for Rust, which explains why the design is this way (it’s a specific choice that avoids allocation for futures, unlike e.g. Go’s goroutines which always heap-allocate the goroutine frame). Once I understood that the trade-off was “Futures live inline where you put them, which requires a way to say don’t-move-me,” Pin stopped feeling arbitrary. It’s a minimum-viable compromise for zero-allocation async.

My practical advice:

  • If you’re writing async fn application code, you don’t need to know any of this. Ignore it.
  • If you’re writing a library that combines futures, learn pin-project-lite, read its readme carefully, and you’ll be fine.
  • If you’re implementing Future by hand, read the std::future and std::pin docs twice, then look at how tokio::time::Sleep is implemented.
  • Box::pin(future) gives you a Pin<Box<dyn Future>> which you can freely move because the Box is the thing that moves; the future stays put. This is the escape hatch when you just want something to work.

I also wrote about async Rust lifetimes in Axum which is where I first hit the Pin-adjacent errors. If you’ve never had to Pin-project anything by hand, count your blessings.