# async-stream: writing Rust streams with async and await notation

> async-stream gives Rust two macros, stream! and try_stream!, that let you build a Stream out of an ordinary async block using yield instead of a channel or a combinator chain. The implementation is a proc macro that rewrites each yield into a send on a lightweight channel, and the crate works on stable Rust without unstable features.

**tokio-rs/async-stream** — Asynchronous streams for Rust using async & await notation

- Repository: https://github.com/tokio-rs/async-stream
- Stars: 780 · Forks: 41
- Language: Rust
- License: NOASSERTION
- Published: 2026-10-20 · Updated: 2026-10-20 · Language: en
- Canonical page: https://hysenlabs.com/projects/tokio-rs-async-stream

## What the two macros give you

async-stream is a crate for asynchronous streams of elements, and it provides two macros, stream! and try_stream!, that let the caller define a stream using async and await notation. The README is explicit that the crate works without unstable features, which matters because several of the alternatives in this space rely on generators or on coroutine machinery that was never stabilised.

The stream! macro returns an anonymous type implementing the Stream trait, and the Item associated type is the type of the values yielded. The try_stream! macro also returns an anonymous type implementing Stream, but its Item is Result<T, Error>, and it supports using the ? notation inside the implementation.

That difference is the whole design in one sentence. stream! is for a stream of values, try_stream! is for a stream of results, and with the second one an error propagates out of the block with ? rather than having to be yielded by hand. A TCP accept loop is the canonical example, and it reads the way the equivalent hand-written code would if the futures crate had a ? operator.

Both macros are procedural macros, and the anonymous return type is what makes the API pleasant: there is no generator type to name, no trait to implement, and no boilerplate. The cost of that pleasantness is that the type is anonymous, so a function that returns one has to spell the return type as impl Stream<Item = T>.

## Writing a first stream and the pinning requirement

The README's first example is a stream that yields numbers.

```rust
use async_stream::stream;

use futures_util::pin_mut;
use futures_util::stream::StreamExt;

#[tokio::main]
async fn main() {
    let s = stream! {
        for i in 0..3 {
            yield i;
        }
    };

    pin_mut!(s); // needed for iteration

    while let Some(value) = s.next().await {
        println!("got {}", value);
    }
}
```

Two things in that snippet are load bearing. Values are yielded with the yield keyword, and the stream block must return (), which is a constraint the macro enforces since a block that ends with an expression of another type will not compile.

The second is the pin_mut! call, and the comment in the README marks it as needed for iteration. The macro produces a stream that is not Unpin, because it holds the state of a suspended async block, and so it has to be pinned before it can be polled. That is a real ergonomic cost, and it shows up in every example in the documentation.

The second example returns the stream from a function, which is the shape you want in real code.

```rust
fn zero_to_three() -> impl Stream<Item = u32> {
    stream! {
        for i in 0..3 {
            yield i;
        }
    }
}
```

The signature is impl Stream<Item = u32> rather than a concrete type, and that works because the macro returns an anonymous type. It is the same trick the async functions in the language use, and it means callers never need to know what the macro generated.

## Composing streams and the try_stream variant

Because the macro produces a Stream, streams compose, and the crate provides for await syntax to make that composition read the way an async function reads.

```rust
fn double<S: Stream<Item = u32>>(input: S)
    -> impl Stream<Item = u32>
{
    stream! {
        for await value in input {
            yield value * 2;
        }
    }
}
```

So a function that takes a stream and returns a stream is written as a stream block with a for await loop inside it, and the result is another anonymous Stream. The generic parameter S with a Stream bound is what lets the function accept any stream, including one produced by the macro.

The try_stream! example is the most useful of the set, because it shows the error path doing the right thing.

```rust
fn bind_and_accept(addr: SocketAddr)
    -> impl Stream<Item = io::Result<TcpStream>>
{
    try_stream! {
        let mut listener = TcpListener::bind(addr).await?;

        loop {
            let (stream, addr) = listener.accept().await?;
            println!("received on {:?}", addr);
            yield stream;
        }
    }
}
```

The bind and the accept both use ?, and the Item type is io::Result<TcpStream>. If the bind fails, the error is produced as the single item of the stream rather than as a panic or a returned Err from a function. If an accept fails, the stream yields that error and then ends, because the ? inside the loop returns from the block.

That is the behaviour a hand-written stream implementation has to reproduce, and getting it right by hand means implementing a state machine that remembers where it was. The macro gets it from the async block machinery, which is the argument for the crate existing.

## How the proc macro turns yield into a send

The implementation section is short and it is worth understanding, because it explains both the design and the limitations.

The stream! and try_stream! macros are implemented using proc macros. The macro searches the syntax tree for instances of yield $expr and transforms them into sender.send($expr).await.

The stream uses a lightweight sender to send values from the stream implementation to the caller. When entering the stream, an Option<T> is stored on the stack, a pointer to the cell is stored in a thread local, and poll is called on the async block. When poll returns, sender.send(value) stores the value in that cell and yields back to the caller.

So the mechanism is a one-slot mailbox. The async block is polled, it runs until it hits a yield, the value goes into a stack cell, a thread local pointer lets the sender find it, and the caller reads it out. Every yield is a send and an await, with no allocation per element beyond what the value itself needs, which is why the crate is described as lightweight.

The thread local is the part that would be a problem in a design that was not built on async blocks, and it is also why the crate works only on stable Rust. The yield points are not arbitrary suspension points in a generator, they are await points in an async block, and the async machinery already knows how to suspend and resume one. The macro does not implement a coroutine; it reuses the language's.

## Where async-stream is the wrong choice

The first limitation is that the macro buys you the shape of a stream, not concurrency. A stream! block with a loop that yields values is a stream whose production is driven by the consumer polling it, and a stream that does real work, such as reading from a socket, needs the awaits inside the block to do that work. That is fine, but it means the crate is not a substitute for a task spawner; the work still happens on the runtime that polls the stream.

The second is the pinning requirement. Every example in the documentation needs a pin_mut! before iteration, and the type is not Unpin, so it cannot be moved after it has been polled. That is a recurring source of friction in code that wants to store a stream in a struct, because a self-referential struct needs pinning to be safe, and the usual solution is a boxed pinned stream, which adds an allocation and a layer of indirection.

The third is that for a straightforward pipeline the futures combinators are better. If you want to map, filter and then collect, chaining .map(), .filter() and .collect() is clearer than a stream block, and it is more obviously correct, since there is no suspension point to reason about. The macro earns its place when the stream's structure is conditional or looping in a way that does not map onto a chain of combinators.

The fourth is release cadence. The recent releases are v0.3.6 from 2024-10-01, v0.3.5 from 2023-04-05 and v0.3.4 from 2023-02-18, and the last push was on 2026-09-05. So the code is being touched while the published version is a couple of years old, and the crate has stayed on 0.3.x, which by the usual semver convention means the API is still considered unstable.

The fifth is that this is a proc macro, so it runs at compile time in your build, which means a build-time dependency, a proc macro build step, and a macro that can be broken by a compiler upgrade in ways a plain library cannot.

## async-stream compared with the futures combinators and with async generators

The comparison with the futures crate's combinators is the one every Rust developer makes first, and the difference in approach is between a stream as a data structure and a stream as a block of code. Combinators build a stream by composing functions, so the shape of the stream is visible in the chain. The macro builds a stream by executing code, so the shape is visible in the control flow.

For a stream that is a fixed transformation of another stream, the combinator version is shorter and easier to read. For a stream whose production depends on runtime state, a loop over a channel, or a select between several sources, the macro version is the only one that does not require you to hand-roll a state machine.

The second comparison is with the async generators proposal, the language feature that would give Rust a native yield syntax. The crates ecosystem has had several attempts at this, and the reason async-stream exists is that none of them landed on stable. The README's emphasis on working without unstable features is the whole point: the crate provides the ergonomics of a generator without depending on a language feature that has been in flux, and it does so by building on async blocks, which are stable.

The third comparison is with a plain channel. A tokio mpsc channel gives you a producer task and a receiver stream, and for many problems that is the simpler answer, because it decouples production from consumption and lets the producer run independently. The macro is the opposite, it keeps the production inline with the consumption, and it is the right tool when you want the backpressure of a pull-based stream.

The fourth is with the crate's own re-export tests. The workspace includes an async-stream/tests/reexporter member and an async-stream/tests/test-reexport member, which are there to check that a downstream crate can re-export the macro and the types, a detail that matters for libraries that want to expose the macro in their own API.

## Licence, versioning and what to check before you add it

The licence field on the repository records a custom licence that GitHub cannot classify, with the terms in the LICENSE file, while the project documentation states that the project is licensed under the MIT license and that any contribution intentionally submitted for inclusion is licensed as MIT without additional terms. The MIT grant is the documented position, and the file is where the exact wording is.

The current minimum supported Rust version is 1.71, and the repository is a Cargo workspace with resolver version 2, with members for the facade crate, the implementation crate and the two re-export test crates. The split into async-stream/ and async-stream-impl/ is the standard arrangement for a proc macro, where the user-facing crate holds the macro and the implementation crate does the work.

The project is not archived and the last push was on 2026-09-05, so it is maintained, with the newest published tag being v0.3.6 from 2024-10-01.

Three things to check before you add the dependency.

The Rust version, since 1.71 is the floor and a project on an older toolchain cannot use it.

The version you pin. Because the macro is expanded into your build, a change in the macro changes the generated code, and a crate on 0.3.x with an unmaintained release history is worth pinning to an exact version rather than following master.

And whether you need the try variant at all. If your stream never produces an error, stream! is simpler, and if your errors are per-item rather than fatal to the stream, then a stream of Result values is what you want and the question of which macro applies goes away.

## Conclusion

async-stream suits Rust code that needs a stream whose shape is decided at runtime, where a combinator chain would be unreadable, and it fits comfortably with the futures and tokio ecosystem since it implements the standard Stream trait. It is the wrong tool for a simple pipeline of fixed transformations, where the combinators in the futures crate are clearer, and it is worth knowing that the crate's release history is slow, with the newest tag from 2024. Before adding it, check the minimum supported Rust version against your toolchain, since the current floor is 1.71, and pin the version rather than tracking master, because the macro is a proc macro and its output is inlined into your build.

## FAQ

### What does the async-stream crate provide?

It provides two macros, stream! and try_stream!, for defining asynchronous streams of elements with async and await notation. The stream! macro returns an anonymous type implementing the Stream trait whose Item is the yielded value, and try_stream! returns one whose Item is Result<T, Error> and which supports the ? notation.

### Does async-stream require unstable Rust features?

No. The README states that the crate works without unstable features, which distinguishes it from the async generator approaches that have not been stabilised. The macros are implemented as proc macros that transform yield expressions into sends on a lightweight channel.

### How do async-stream streams compose with other streams?

Because the macro returns a type implementing the standard Stream trait, it composes with anything else in the futures ecosystem, and the crate adds for await syntax so a stream can be consumed inside another stream block and a new stream yielded from it.

### What is the minimum supported Rust version for async-stream?

The current minimum supported Rust version is 1.71, and the crate is published as version 0.3.x, with the most recent tag being v0.3.6 from 2024-10-01.

### Why does the async-stream documentation use pin_mut!?

The comment in the examples marks it as needed for iteration. The macro produces a stream that is not Unpin, because it holds the suspended state of an async block, so it must be pinned before it can be polled.

### What licence is async-stream released under?

The project documentation states it is licensed under the MIT license, with the terms in the LICENSE file, and that contributions are licensed as MIT without additional terms. The licence field on the repository records a custom licence that GitHub cannot classify.

## Sources

- [Issues](https://github.com/tokio-rs/async-stream/issues)
- [README](https://github.com/tokio-rs/async-stream/blob/master/README.md)
- [Releases](https://github.com/tokio-rs/async-stream/releases)
- [tokio-rs/async-stream on GitHub](https://github.com/tokio-rs/async-stream)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/tokio-rs-async-stream
