Axum 0.8 Changes and How to Migrate Your Routes Explained
Axum 0.8 changes path syntax from :id to {id}, removes the need for async_trait on extractors, tightens Option extractors and requires Sync handlers. Migrate step by step with before and after code.

Axum 0.8 is a small release for most applications. The headline change is a new path syntax; the rest is cleanup that removes boilerplate. This post lists each change with before and after code so you can migrate in one sitting. The examples target Rust 1.90 with edition 2024, Axum 0.8 and Tokio 1.47.
1. Path syntax: {param} and {*wildcard}
// 0.7
let app = Router::new()
.route("/users/:id", get(user))
.route("/files/*path", get(file));
// 0.8
let app = Router::new()
.route("/users/{id}", get(user))
.route("/files/{*path}", get(file));The new form aligns with format! and with OpenAPI, and frees : and * to appear literally in paths. To match a literal brace, double it: /{{literal}}. Using the old syntax panics when the router is built:
thread 'main' panicked at src/main.rs:8:
Invalid route "/users/:id": Path segments must not start with `:`. For capture groups, use `{capture}`. If you meant to literally match a segment starting with a colon, call `without_v07_checks` on the router.The extractor side is unchanged: Path<u32>, Path<(u32, String)> and Path<MyStruct> work as before, and names in the struct map to the braces.
2. Extractors: drop #[async_trait]
use axum::{extract::FromRequestParts, http::{request::Parts, StatusCode}};
struct ApiKey(String);
// 0.7 needed #[async_trait] here
impl<S: Send + Sync> FromRequestParts<S> for ApiKey {
type Rejection = StatusCode;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
parts
.headers
.get("x-api-key")
.and_then(|v| v.to_str().ok())
.map(|k| ApiKey(k.to_owned()))
.ok_or(StatusCode::UNAUTHORIZED)
}
}The traits use native async fn in trait now (async traits then and now). Remove the macro and the async-trait dependency if nothing else uses it. A leftover #[async_trait] usually still compiles but is redundant.
3. Option extractors
// 0.7: Option<T> turned ANY rejection into None
async fn handler(user: Option<Json<Payload>>) {}
// 0.8: Option<T> works only when T: OptionalFromRequest(Parts)
// and a malformed body is now an error, not NoneThis closes a long-standing footgun: a client sending invalid JSON used to be indistinguishable from sending none. Now Option<Json<T>> gives None only when the Content-Type header is absent, and rejects a bad body. If you relied on the old behavior for your own extractor, implement OptionalFromRequestParts and decide what absent means. If you want the old swallow-everything behavior on a built-in extractor, take Result<T, T::Rejection> and match.
4. Sync requirement
Handlers and services now need to be Sync in addition to Send, mostly relevant if you store a non-Sync value in state or a closure. The fix is to wrap it in a Mutex or use an Arc<Mutex<T>>. A compile error mentioning Sync on a handler points here.
5. Smaller changes
Hostextractor moved toaxum-extra.serveaccepts anyListener, so Unix sockets and custom acceptors work without extra glue.WebSocketmessages useUtf8BytesandBytesinstead ofStringandVec<u8>; wrap with.into()when constructing aMessage::Text.Router::method_not_allowed_fallbacklets you customize the response for a path that exists but not for that method.
Migration checklist
- Bump
axum,axum-extra,tower-httpandtokiotogether; runcargo updateandcargo tree -dto check for two versions ofhttporhyper(duplicate versions). - Search and replace route strings:
/:nameto/{name},/*restto/{*rest}. - Remove
#[async_trait]from extractor impls. - Fix
Option<...>extractors deliberately: decide what a malformed input should do. - Run the router-building test and the integration tests (integration tests).


