Why This Hits Python Developers Like a Brick Wall

If you have spent a few years writing Python, the syntax of Rust's async/await looks reassuring. There is an async fn, there is a .await, there is even a function called main that you can mark async. You write your first program, the compiler hits back with a wall of red text about Pin, Send, 'static, Unpin, and Future cannot be unpinned, and suddenly the familiarity feels like a Trojan horse.

The reason is straightforward. Python's asyncio and Rust's async/await were designed to solve the same surface problem — running thousands of concurrent I/O operations on a small number of threads — but the underlying machinery is built on opposite philosophies. Python hides the runtime inside the standard library and assumes a single thread. Rust pushes the runtime into a third-party crate and assumes you might want to use every core on the machine.

This article is the mental model I wish someone had handed me on day one of writing Rust services after years of Python. It is built from the official Rust Book Chapter 17, the Tokio tutorial, Cloudflare's writeup on Pin, and a handful of community deep dives. Every comparison below assumes you already know how async def and await behave in Python.

The Timeline and the Surface Similarity

According to Jack O'Connor's "Async Rust in Three Parts", the major languages picked up async/await in roughly this order: C# in 2012, Python in 2015, JavaScript in 2017, Rust in 2019, and C++ in 2020. Python had a four-year head start on Rust, and that gap shaped the design choices. Python could lean on a mature interpreter and a global interpreter lock; Rust had to bolt async onto a language with no garbage collector, no runtime, and a borrow checker that audits every reference at compile time.

Here is the syntactic side-by-side. The Python version:

import asyncio

async def add(a, b):
    return a + b

async def main():
    result = await add(10, 20)
    print(result)

asyncio.run(main())

And the Rust version, using Tokio:

#[tokio::main]
async fn main() {
    let result = add(10, 20).await;
    println!("{result}");
}

async fn add(a: u8, b: u8) -> u8 {
    a + b
}

Four differences are worth burning into memory before we go any further.

First, await is a postfix keyword in Rust. You write expr.await, not await expr. The Rust Book justifies this choice on the grounds of method chaining: trpl::get(url).await.text().await reads left to right, while the Python equivalent forces you into nested parentheses like (await (await response).text()). Once you have written a few HTTP clients, the postfix form starts to feel less like a quirk and more like a quality-of-life upgrade.

Second, Rust has no built-in async runtime. Python ships asyncio in the standard library; Rust ships nothing. You pick a runtime — almost always Tokio — and add it as a dependency. The #[tokio::main] attribute you see above is a macro that expands into the boilerplate of building a runtime and calling block_on on your async body.

Third, fn main cannot be async in Rust. The macro creates a synchronous main for you. Without it, you have to call something like futures::executor::block_on(my_async_fn()) yourself.

Fourth, and most importantly, calling an async function in Rust does nothing. In Python, calling a coroutine without awaiting it produces a coroutine object and a RuntimeWarning. In Rust, the same call produces a Future value that the compiler may warn about, but it will silently sit there, unexecuted, until something polls it.

Futures Are Not Threads, They Are Inert Data

The single most important quote in the entire Rust async ecosystem comes from the Tokio tutorial: "A Rust future does not represent a computation happening in the background, rather the Rust future is the computation itself." Jack O'Connor's article phrases it the other way around: "Futures don't do any work 'in the background.' Instead, they do their work when we .await them."

If Python's coroutines are like a food truck that starts cooking as soon as you place an order, Rust futures are more like the recipe card. Nothing happens until someone (the runtime) actually picks up the card and starts following the steps. That recipe card is, mechanically, a struct that the compiler generates for you, with one variant per await point in your function.

This is the famous stackless coroutine model. Python's coroutines are stackful: the interpreter maintains a separate call stack frame per coroutine, and the event loop jumps between those frames. Rust's coroutines are stackless: the compiler eliminates the stack frame entirely and replaces it with an enum where each variant captures the local variables alive at that suspension point.

A simplified mental picture for an async function with two await points might look like this:

enum PageTitleFuture<'a> {
    Initial { url: &'a str },
    GetAwaitPoint { url: &'a str },
    TextAwaitPoint { response: trpl::Response },
}

Every time the runtime polls this future, it inspects which variant it is currently in, advances the work, and either transitions to the next variant or returns a final value. Because there is no runtime allocation per coroutine, this design is what people mean when they call Rust async "zero-cost." There is no hidden stack to manage, no garbage collector to run, no interpreter overhead.

The Future Trait Is Not a Promise

Under the covers, every async function returns something that implements this trait, taken straight from the Rust Book Chapter 17-05:

pub trait Future {
    type Output;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

Three things about this signature are worth lingering on, because they explain ninety percent of the puzzling compile errors that newcomers hit.

The return type is a Poll<T> enum with two variants: Ready(T) if the work is done, or Pending if the future needs to be polled again later. There is no callback, no promise resolution, no event subscription. The runtime is in charge of asking "are you done yet?" by calling poll, and the future is in charge of answering.

The cx: &mut Context<'_> parameter carries a Waker. The Tokio docs lay down a non-negotiable rule: when a future returns Poll::Pending, it must arrange for the waker to be signaled at some point later, otherwise the task will hang indefinitely. The waker is how the future tells the runtime "the I/O I was waiting on is now ready, please poll me again." Python has no equivalent concept because Python's event loop manages readiness internally through callback registration on the underlying I/O selector.

The self: Pin<&mut Self> parameter is the part that makes everyone sigh on first reading. We will come back to it shortly.

When the compiler sees my_future.await, it generates a loop that is roughly equivalent to:

loop {
    match my_future.poll(cx) {
        Poll::Ready(value) => break value,
        Poll::Pending => /* yield to the runtime */,
    }
}

That is the entire trick. await is sugar for a polling loop with a yield point.

Pin and Unpin: The Tax You Pay for Stackless Coroutines

Here is the problem Pin exists to solve, paraphrased from Cloudflare's excellent writeup. Imagine the compiler generates a state machine struct that, between two await points, needs to hold a reference into one of its own fields:

struct Fut {
    data: u32,
    reference_to_data: &u32,  // Points to data field above
}

If Rust ever moved this struct in memory, the data field would land at a new address while reference_to_data would still point at the old one. Reading through that pointer would be undefined behavior. In a language where every other type can be moved freely, this is a real hazard, and async functions produce self-referential structs all the time because local variables borrowing each other are completely normal.

Pin<P> is the safety net. It is not a pointer, it is a wrapper around a pointer that says: "the value behind this pointer cannot move in memory." The wrapper itself can move freely; what is pinned is the underlying address. This is why Future::poll takes Pin<&mut Self> rather than the usual &mut self. The runtime has to promise the future that its memory address is stable across calls to poll.

Most types in Rust are also Unpin, an automatically implemented marker trait that says "actually, it is fine to move me even when I am behind a Pin." Plain i32, String, Vec<T>, and most user-defined structs all satisfy Unpin. Futures generated by async blocks, however, are typically !Unpin — the compiler refuses to give them the marker, because they might be self-referential.

This is the source of the most baffling first-time error message in Rust async. You try to put several futures in a Vec and pass them to join_all, and the compiler throws this back at you:

error[E0277]: `dyn Future<Output = ()>` cannot be unpinned

The fix, straight from Chapter 17, is to use the pin! macro to pin each future to the stack:

use std::pin::pin;

let tx_fut = pin!(async move { /* ... */ });
let rx_fut = pin!(async { /* ... */ });

let futures: Vec<Pin<&mut dyn Future<Output = ()>>> = vec![tx_fut, rx_fut];
trpl::join_all(futures).await;

For more complex cases — wrapping someone else's future in your own — the community standard is the pin-project crate, which generates safe pinned-projection accessors via the #[pin] attribute on individual fields. Cloudflare's article walks through a TimedWrapper example that times any inner future, and it is worth reading once you start writing your own combinators.

Nothing in Python looks like this. CPython never moves objects after allocation, and the garbage collector keeps everything alive. The price you pay is interpreter overhead and a permanent ceiling on raw throughput. Rust gives you the lower ceiling, but it makes you sign the safety waiver in the form of Pin.

Send, Sync, and 'static: The Multi-Thread Tax

Here is where the philosophical gap between asyncio and Tokio gets the widest. The Tokio spawning tutorial lays out the contract: any future you hand to tokio::spawn must be Send + 'static.

Send means the value can be moved to another thread. 'static means it does not borrow any data that might be dropped before the task finishes. Tokio enforces both at compile time, because by default it runs your tasks on a multi-thread work-stealing scheduler with one worker per CPU core. A task that suspends on the first worker may resume on a different worker, and any data it carries across the await point comes along for the ride.

Here is the pitfall every Python developer hits in their first week:

tokio::spawn(async {
    let rc = Rc::new("hello");
    yield_now().await;          // rc is held across the await point
    println!("{}", rc);         // ERROR: Rc is not Send
});

Rc is the single-threaded reference-counted pointer. It is not Send, by design — its reference count is not atomic. The compiler refuses to compile this code because the value is held across an await point, which means it might need to migrate threads. The fix is to use Arc (atomic reference counting) instead, or to confine the Rc to a scope that ends before the await:

tokio::spawn(async {
    {
        let rc = Rc::new("hello");
        println!("{}", rc);
    }                           // rc dropped here
    yield_now().await;          // nothing non-Send is held across this point
});

Python has no equivalent of this check. With the GIL and a single-threaded event loop, there is no concept of "can this object be sent to another thread?" — your coroutines simply do not move between threads. The Tokio docs put it bluntly: Tokio "enforces compile-time safety through Send and 'static bounds, preventing common threading and lifetime issues at compilation rather than runtime."

The 'static part trips people up too. Spawned tasks may outlive the function that spawned them, so they cannot borrow local variables. The conventional fix is async move, which moves ownership of captured variables into the task.

Tokio vs the Python Event Loop

Think of Python's asyncio as a single ride at a theme park: one event loop, one operator, one queue. Tasks line up, the operator runs them in order, and if any one of them blocks, the whole line stops. It is simple, predictable, and easy to reason about. It also leaves N-1 of your CPU cores napping.

Tokio, by default, is more like a NASCAR pit crew: one worker per CPU core, each with its own local task queue, plus a work-stealing algorithm so that an idle worker can grab tasks from a busy one. The #[tokio::main] macro expands to roughly this:

fn main() {
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(async { /* your async main body */ })
}

The consequences ripple through the whole programming model. Python developers are used to the idea that as long as they do not call time.sleep, their coroutines are inherently safe from races within a single event loop. In Tokio, the moment you spawn a second task you are writing concurrent code that the borrow checker will police accordingly.

The corrode.dev report on the State of Async Rust cites the popular criticism succinctly: "The Original Sin of Rust async programming is making it multi-threaded by default." Whether or not you agree, the consequence is real: Arc<Mutex<T>> shows up everywhere in Tokio codebases, where Python would have simply mutated a shared dict.

The Six Mistakes Python Developers Will Make

Distilled from the Qovery engineering blog and the cross-references in the Rust Book, here are the patterns that bite hardest when you bring a Python intuition into Rust async.

Forgetting .await. In Python, a missing await produces a runtime warning. In Rust, it produces a Future value sitting in a variable, doing nothing, while the rest of your function moves on. The compiler will sometimes warn about an unused Future, but if you bind it to a variable the warning disappears. Train your eyes to spot this.

Calling blocking code from an async function. std::thread::sleep inside an async function freezes the entire Tokio worker thread. So does std::fs::read_to_string, or any synchronous database driver, or a CPU-heavy loop. The async equivalents (tokio::time::sleep, tokio::fs) yield properly. When you genuinely need to call blocking code, wrap it in tokio::task::spawn_blocking, which moves the work to a dedicated thread pool. Python has the same hazard with time.sleep, but the GIL provides at least partial protection from the worst-case behavior. Tokio offers no such cushion.

Misunderstanding cancellation. In Python, cancelling a task raises CancelledError at the next await, which you can catch and clean up around. In Rust, cancellation happens by simply dropping the future. No exception is raised, no finally block runs, and any code you wrote after a .await may simply never execute. The community pattern is to use RAII guards — values whose Drop implementations do the cleanup — rather than relying on post-await statements.

Holding a mutex guard across an .await. This is a starvation bug that is trivial to write and painful to debug. If you take a tokio::sync::Mutex lock and then await a slow operation while still holding it, every other task that wants the lock waits on you. The fix is to scope the lock tightly:

let rules = {
    let mut conn = pool.get().await.unwrap();
    redis::fetch(&mut conn).await
};  // conn dropped here

for rule in rules {
    process_rule(rule).await;  // no lock held during the slow part
}

A related rule of thumb: if you do not actually hold a lock across an .await, prefer std::sync::Mutex over tokio::sync::Mutex. The async mutex is slower and only earns its keep when you genuinely need to hold a lock across suspension points.

Starving the accept loop. A classic Python mistake is calling a slow coroutine directly inside the connection-accept loop instead of dispatching it to asyncio.create_task. The Rust version is the same shape:

loop {
    let cnx = listener.accept().await;
    tokio::spawn(async move { process_client(cnx).await });  // not directly awaited
}

Without the tokio::spawn, your server processes one client at a time and looks broken under load.

Treating Rc like Arc. Already covered above, but worth repeating because it is the single most common compile error for Python newcomers. If a value crosses an await point inside a tokio::spawn, it has to be Send. Rc is not. Arc is.

The Ecosystem in 2025

The corrode.dev runtime survey, current as of 2024, reports that Tokio is used at runtime by more than twenty thousand crates on crates.io, which is most of the ecosystem. Tokio's LTS (long-term support) cadence currently covers 1.36.x through March 2025, 1.38.x through July 2025, and 1.43.x through March 2026.

The biggest recent change is that async-std, the runtime that tried to mirror the standard library API, was officially discontinued on March 1, 2025. The Weekly Rust newsletter's "Goodbye Async-Std, Welcome Smol" recommends migrating to a lightweight executor with a similar API surface. For specialized work, some runtimes target #[no_std] embedded environments, and others offer a thread-per-core model on top of Linux's io_uring.

Unless you have a specific reason, Tokio is the safe default, and corrode.dev's overall guidance is worth quoting almost verbatim: learn synchronous Rust first, default to synchronous code, and reach for async only when you have I/O or external services that justify it. "Modern operating systems come with highly optimized schedulers," the article notes, and threading often outperforms async at lower concurrency levels. The traditional break-even point is around ten thousand concurrent connections, where the per-thread stack cost (roughly 8 MiB on Linux) starts to dominate.

What This Means If You Are Coming From Python

The practical takeaway is that async Rust is not async Python with stricter syntax. It is a different machine wearing a similar costume. The borrow checker is not going to let you skim the way you skim Python; every value held across an .await becomes a question about ownership, threading, and lifetimes. The reward is real — measurable throughput, no GC pauses, and compile-time guarantees about thread safety that Python cannot offer at any price — but the on-ramp is genuinely steeper, and pretending otherwise leads to a week of frustration.

The path that works for most Python developers is to start synchronous, write a few hundred lines of plain Rust until ownership feels natural, then add Tokio for one specific I/O concern at a time. Trying to write an async-first Rust program before the borrow checker stops surprising you is like learning to drive a manual transmission in heavy city traffic — technically possible, but you will burn the clutch on the first hill.

Key Takeaways

  • Same syntax, different machine. Python's asyncio is single-threaded and runtime-managed; Tokio is multi-threaded by default and demands compile-time proof of thread safety via Send and 'static.
  • Futures are inert. A Rust Future does nothing until it is polled. Forgetting .await produces no runtime error, only a value that silently does nothing.
  • Pin is the tax for stackless coroutines. Async state machines can be self-referential, so the Future::poll signature requires a pinned &mut self. Use the pin! macro or the pin-project crate when you hit cannot be unpinned errors.
  • Rc across .await will not compile. Use Arc whenever a value needs to live across a suspension point inside tokio::spawn. This is the most common first-week compiler error for Python developers.
  • Default to synchronous, reach for Tokio deliberately. corrode.dev's guidance is that modern OS schedulers handle threading well; async earns its keep at very high concurrency or when integrating with an async-first library like reqwest, axum, or sqlx. Async-std is officially discontinued as of March 1, 2025; a lightweight successor is the recommended alternative.

Disclaimer

This article is for informational and educational purposes only and does not constitute financial, investment, legal, or professional advice. Content is produced independently and supported by advertising revenue. While we strive for accuracy, this article may contain unintentional errors or outdated information. Readers should independently verify all facts and data before making decisions. Company names and trademarks are referenced for analysis purposes under fair use principles. Always consult qualified professionals before making financial or legal decisions.