Open-source project
dtolnay/async-trait avatar
dtolnay/async-trait

async-trait: how Rust erases async fn in traits into a boxed future

Type erasure for async trait methods

2,202 stars101 forksRustApache-2.0

At a glance

What is it?
async-trait is a proc macro by dtolnay that makes a trait containing async fn usable as dyn Trait, a combination the compiler still rejects. It works by turning every method into a boxed future and adding Send and Sync bounds that native async fn in traits do not require.
Who is it for?
Adopt async-trait when a trait has to be erased into a dyn object, and accept that every call allocates a boxed future and that Send and Sync bounds land on types you did not write them for. Leave it out for statically dispatched code, where native async fn in traits compiles with no macro and no box.
Can I use it commercially?
Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 11 days ago.
What is it written in?
Mainly Rust, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 1, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The E0038 error async-trait was written around

Rust 1.75 stabilized async fn in traits, and the README opens by stating what that stabilization did not include: support for using a trait that contains async functions as `dyn Trait`. The example the crate gives is a three-line trait and one function signature.

rust
pub trait Trait {
    async fn f(&self);
}

pub fn make() -> Box<dyn Trait> {
    unimplemented!()
}

The compiler answers with `error[E0038]: the trait `Trait` is not dyn compatible`, and the diagnostic gives the reason plainly. A trait has to allow building a vtable, and this one cannot, because method `f` is `async`. The same output ends with a single piece of advice, to move `f` to another trait, which in practice means reshaping the public API rather than editing a method body.

`async-trait` removes that error without asking you to move anything. The crate description is one phrase, type erasure for async trait methods, and that phrase is the whole design. It sits exactly where a concrete type gets erased into a trait object, and it pays for the erasure with a boxed future on every call. The cost is not hidden in the README, it is printed as an expansion, which is unusual and useful.

What the macro generates: a boxed future and a Self: Sync bound

The README's Explanation section shows the expansion. An async fn in a trait becomes an ordinary method returning `Pin<Box<dyn std::future::Future<Output = ()> + Send + 'async_trait>>`, and its body is `Box::pin(async move { ... })` wrapped around the original code.

rust
impl Advertisement for AutoplayingVideo {
    fn run<'async_trait>(
        &'async_trait self,
    ) -> Pin<Box<dyn std::future::Future<Output = ()> + Send + 'async_trait>>
    where
        Self: Sync + 'async_trait,
    {
        Box::pin(async move {
            /* the original method body */
        })
    }
}

Three details in that signature carry more weight than the syntax. The `Send` bound on the boxed future forces every future a trait produces to be movable between threads. The `Self: Sync + 'async_trait` where clause is injected even though the method takes only `&self`, because the box has to be safe to hand elsewhere. And the lifetime is renamed to `'async_trait` so it cannot collide with a lifetime you wrote yourself.

The README also states that there is no use of `unsafe` in the expanded code. That is the practical argument for trusting the macro: if your code compiles, the erasure did not produce something unsound, which is more than most code-generating macros promise.

Declaring your first erased trait with async-trait

async-trait is a proc-macro crate, published on crates.io and documented on docs.rs. The repository documents no install command of its own, so the dependency is declared the ordinary Cargo way, and the published version in the manifest is 0.1.92.

toml
[dependencies]
async-trait = "0.1.92"

Then import the macro and place it above the trait and above every impl that contains async fn.

rust
use async_trait::async_trait;

#[async_trait]
trait Advertisement {
    async fn run(&self);
}

struct Modal;

#[async_trait]
impl Advertisement for Modal {
    async fn run(&self) {
        self.render_fullscreen().await;
    }
}

The attribute has to be on both sides. A trait declared with the macro and an impl written without it produce mismatched signatures, and that mismatch is the most common first failure. Once both sides carry the attribute, the trait works in the erased positions the README advertises, such as `Vec<Box<dyn Advertisement + Sync>>` or `&[&dyn Advertisement]`.

?Send futures and what the opt-out removes

Not every async trait needs futures that are `dyn Future + Send`. The README gives the switch: invoke the macro as `#[async_trait(?Send)]` on both the trait and the impl blocks.

rust
#[async_trait(?Send)]
trait Advertisement {
    async fn run(&self);
}

The stated purpose is to avoid having Send and Sync bounds placed on the async trait methods. That removes the `Self: Sync` clause visible in the default expansion, so a type holding a non-Sync interior stops being rejected at the impl site. Single-threaded executors and types that carry an `Rc` or another non-Send handle across an await point are the cases this exists for.

The trade is that a future which is not Send cannot be moved onto a thread, so a trait declared with `?Send` cannot produce futures for a task spawned on a multithreaded runtime. Either form still allocates a box per call, so `?Send` buys you fewer bounds rather than less overhead.

Elided lifetimes and error E0726 on trait methods

Async fn syntax does not allow lifetime elision outside `&` and `&mut` references, and the README notes this is true even when the macro is not involved. A trait method that takes a type alias carrying a lifetime by value runs straight into it.

rust
type Elided<'a> = &'a usize;

#[async_trait]
trait Test {
    async fn test(not_okay: Elided, okay: &usize) {}
}

The compiler reports `error[E0726]: implicit elided lifetime not allowed here` and points at the parameter, offering the placeholder `'_` as the remedy. Either fix works, and the README shows both.

rust
#[async_trait]
trait Test {
    async fn test<'e>(elided: Elided<'e>) {}
}

This is a property of async fn rather than of this crate, but it lands in code you are already rewriting to use async_trait, and the failure arrives as a compiler error rather than a warning. A signature that compiled as a plain fn can stop compiling once the keyword async is added.

Reading Cargo.toml for the upgrade cost

The repository is not archived and the last push was on 2026-09-21. Releases are frequent and stay on the same minor line: 0.1.90 on 2026-07-18, 0.1.91 on 2026-07-26, 0.1.92 on 2026-08-08. Since the version never leaves 0.1.x, updating the dependency does not force edits in your source.

The library itself depends on two lightweight procedural macro crates and syn, with visit-mut enabled so the macro can rewrite existing signatures rather than only append to them.

toml
[dependencies]
proc-macro2 = "1.0.74"
quote = "1.0.35"
syn = { version = "3", default-features = false, features = ["clone-impls", "full", "parsing", "printing", "proc-macro", "visit-mut"] }

The dev-dependencies describe the test strategy. `trybuild` with the `diff` feature compiles UI tests and reports the rendered compiler error, which matches the two diagnostics printed in the README. `rustversion` gates tests by compiler version, and `tracing` with `tracing-attributes` gives the crate a downstream consumer that is itself an attribute macro, so async-trait is exercised against code similar to what it generates.

`build.rs` exists in the repository but appears under `exclude` in Cargo.toml, so it is not shipped in the published package. The declared minimum is `rust-version = "1.71"` on edition 2021, while the licence is `MIT OR Apache-2.0` and both LICENSE-APACHE and LICENSE-MIT sit in the repository root. Repository metadata displays a single Apache-2.0 badge, which is only one half of the pair.

When the macro is the wrong tool

The overhead is legible in the expansion rather than buried in documentation. Every call to an erased async trait method allocates a box for the future, where a native async fn in a trait with static dispatch produces none, and every such call goes through the vtable instead of being inlined.

It also relocates errors rather than preventing them. Since the macro rewrites signatures, the borrow checker and the trait solver reason about types you did not write, and the README asks users to file an issue when unexpected borrow checker errors, type errors, or warnings appear. That request is an honest admission that the edge cases are numerous. The supported-features list is framed as an intention: Self by value, reference, mut reference or no self, any argument list, any return value, generics, lifetimes, associated types, mixed async and non-async methods, default implementations, elided lifetimes. None of that is a compatibility matrix, and a trait that needs a pattern outside the list can fail to compile in a way that reads like a borrow checker bug.

Where you control every call site and the trait stays inside one crate, this crate is the wrong tool.

Native async fn in traits versus a boxed dyn future

The real alternative is to skip erasure entirely. Since Rust 1.75 an async fn in a trait compiles without any macro, so statically dispatched code declares async fn directly and gets no box, no dyn and no injected where clause. What the compiler still refuses is the `Box<dyn Trait>` form, as the opening error shows.

These approaches serve different dispatch strategies rather than competing on quality. Static dispatch needs the concrete type known at compile time. A trait object needs the caller to hold implementations it never names, and that is the case async-trait exists for. Mixing the two in one API is what makes the choice awkward, because the signature a caller depends on changes depending on which form you picked.

The README points to *why async fn in traits are hard* for a deeper account of how this implementation differs from what the compiler and the language deliver natively, and that link is the right reading before you decide which side of the line your code belongs on.

Editorial conclusion

Adopt async-trait when a trait has to be erased into a dyn object, and accept that every call allocates a boxed future and that Send and Sync bounds land on types you did not write them for. Leave it out for statically dispatched code, where native async fn in traits compiles with no macro and no box. Verify first that every implementor satisfies the Self: Sync bound shown in the expansion, because that injected bound is the one most likely to reject a type you already have.

Frequently asked questions

What does the async-trait crate actually do to my async methods?

Each async fn in the trait becomes a method returning Pin<Box<dyn Future + Send + 'async_trait>> whose body wraps the original code in Box::pin(async move { ... }). The README states that the expanded code contains no unsafe.

How do I use async-trait with futures that are not Send?

Invoke the macro as #[async_trait(?Send)] on both the trait and the impl blocks. The README presents this as the way to stop Send and Sync bounds being placed on the async trait methods, which is what you want on a single-threaded executor or for a type holding an Rc.

What is the minimum Rust version that async-trait supports?

Cargo.toml declares rust-version = "1.71" with edition 2021, so the crate builds on 1.71 even though the dyn compatibility problem it solves is the one Rust 1.75 did not cover.

Why do I get error E0726 on an async trait method?

Async fn syntax does not allow lifetime elision outside & and &mut references, so a parameter using a lifetime-carrying alias such as Elided must be given a named lifetime or the '_ placeholder. The README notes this applies even without the macro.

How is async-trait licensed and can I use it commercially?

The repository ships both LICENSE-APACHE and LICENSE-MIT and the manifest declares MIT OR Apache-2.0, so either licence may be chosen at your option. This is not legal advice; check the two files for the terms themselves.

When was async-trait last released, and is it still worked on?

The most recent release listed is 0.1.92 on 2026-08-08, following 0.1.91 on 2026-07-26 and 0.1.90 on 2026-07-18. The repository is not archived and the last push was on 2026-09-21.

Official sources

  1. dtolnay/async-trait on GitHub
  2. Issues
  3. License: Apache-2.0
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/dtolnay-async-trait.svg)](https://hysenlabs.com/projects/dtolnay-async-trait)