Migration & Framework Comparison

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against axum 0.8.9 (released 2026-04-14) with axum-extra 0.12.6 on Rust 1.99 (edition 2024), checked on 2026-10-06. Tutorials, books and answers on the web span axum 0.4 to 0.8, and each minor release before 1.0 breaks something. This page collects what changed, shows the current form, and compares axum with the other Rust web frameworks. The authoritative list of changes is the axum CHANGELOG (and axum-core’s, for the extractor traits).

From 0.6 to 0.7

axum 0.7 (November 2023) moved to hyper 1.0 and http-body 1.0. Code in the 0.6 style:

// axum 0.6 with hyper 0.14 -- does not compile with axum 0.7 or 0.8
use axum::{
    Router, Server, TypedHeader,
    body::Body,
    extract::Path,
    headers::UserAgent,
    http::Request,
    middleware::{self, Next},
    response::Response,
    routing::get,
};

async fn book(Path(id): Path<u32>, TypedHeader(agent): TypedHeader<UserAgent>) -> String {
    format!("book {id} for {agent}\n")
}

async fn timing<B>(req: Request<B>, next: Next<B>) -> Response {
    next.run(req).await
}

#[tokio::main]
async fn main() {
    let app: Router<(), Body> = Router::new().route("/books/:id", get(book)).layer(middleware::from_fn(timing));
    Server::bind(&"0.0.0.0:3000".parse().unwrap()).serve(app.into_make_service()).await.unwrap();
}
  • axum::Server (a re-export of hyper 0.14’s server) is gone: use axum::serve(listener, app) with a tokio::net::TcpListener, or hyper-util for full control.

  • axum has its own axum::body::Body; Router, Next, MethodRouter and the extractor traits are no longer generic over the body type, so Router<S, B> becomes Router<S> and Next<B> becomes Next. axum::extract::Request is Request<Body>. BoxBody, Full and Empty are replaced by Body::new, Body::from and Body::empty.

  • TypedHeader and the headers re-export moved to axum-extra (feature typed-header): axum_extra::TypedHeader and axum_extra::headers::UserAgent.

  • hyper::body::to_bytes is gone; use axum::body::to_bytes(body, limit) or http_body_util::BodyExt::collect.

  • tower-http had to move to 0.5 with it; it is at 0.7 now.

From 0.7 to 0.8

axum 0.8 (January 2025) changed the path syntax and the extractor traits. The 0.7 style:

// axum 0.7 -- compiles with 0.7, panics or fails to compile with 0.8
#[async_trait]
impl<S: Send + Sync> FromRequestParts<S> for CurrentUser {
    type Rejection = StatusCode;
    async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> { /* ... */ }
}

async fn files(Path(path): Path<String>, Host(host): Host, user: Option<CurrentUser>) -> String { /* ... */ }

let app = Router::new()
    .route("/books/:id", get(book))
    .route("/files/*path", get(files))
    .route("/ws", get(echo)); // Message::Text(String)

The same in 0.8:

use axum::{
    Router,
    extract::{
        FromRequestParts, OptionalFromRequestParts, Path,
        ws::{Message, WebSocket, WebSocketUpgrade},
    },
    http::{StatusCode, header, request::Parts},
    response::Response,
    routing::{any, get},
};
use axum_extra::{TypedHeader, headers::UserAgent};

struct CurrentUser(String);

// 0.8: native async fn in the trait -- no #[async_trait]
impl<S: Send + Sync> FromRequestParts<S> for CurrentUser {
    type Rejection = StatusCode;

    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        let value = parts.headers.get("x-user").ok_or(StatusCode::UNAUTHORIZED)?;
        Ok(CurrentUser(value.to_str().map_err(|_| StatusCode::BAD_REQUEST)?.to_owned()))
    }
}

// 0.8: Option<CurrentUser> needs its own impl, so "absent" and "invalid" can differ
impl<S: Send + Sync> OptionalFromRequestParts<S> for CurrentUser {
    type Rejection = StatusCode;

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Option<Self>, Self::Rejection> {
        if parts.headers.contains_key("x-user") {
            <CurrentUser as FromRequestParts<S>>::from_request_parts(parts, state).await.map(Some)
        } else {
            Ok(None)
        }
    }
}

async fn book(Path(id): Path<u32>, TypedHeader(agent): TypedHeader<UserAgent>) -> String {
    format!("book {id} for {agent}\n")
}

async fn files(Path(path): Path<String>, parts: Parts, user: Option<CurrentUser>) -> String {
    // the Host extractor moved to axum-extra; reading the header is often enough
    let host = parts.headers.get(header::HOST).and_then(|h| h.to_str().ok()).unwrap_or("-");
    let who = user.map_or("anonymous".to_owned(), |u| u.0);
    format!("{path} on {host} for {who}\n")
}

async fn echo(ws: WebSocketUpgrade) -> Response {
    ws.on_upgrade(|mut socket: WebSocket| async move {
        while let Some(Ok(Message::Text(text))) = socket.recv().await {
            // 0.8: Text carries Utf8Bytes (was String); .as_str() reads it, .into() builds one
            let reply = format!("echo: {}", text.as_str());
            if socket.send(Message::Text(reply.into())).await.is_err() {
                break;
            }
        }
    })
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/books/{id}", get(book)) // was "/books/:id"
        .route("/files/{*path}", get(files)) // was "/files/*path"
        .route("/ws", any(echo)); // any(): WebSockets over HTTP/1.1 and HTTP/2

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap(); // was axum::Server::bind(..).serve(..)
}
$ curl -A curl/8.5 localhost:3000/books/42
book 42 for curl/8.5
$ curl -H 'x-user: ada' localhost:3000/files/covers/42.png
covers/42.png on localhost:3000 for ada
# and with the old syntax, at start-up:
thread 'main' panicked: 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.
  • Path syntax: /:id becomes /{id} and /*rest becomes /{*rest}. The old syntax panics when the route is added, so the mistake shows up at start-up rather than as silent 404s. {{ and }} escape literal braces.

  • Extractors: FromRequest and FromRequestParts use native async fn in traits; remove #[async_trait], or the impl fails with E0195 (see Custom Extractors).

  • Optional extractors: Option<T> works only for types implementing OptionalFromRequestParts (or OptionalFromRequest), and it no longer swallows every error: Option<Path<T>> rejects malformed values instead of returning None. Option<Query<T>> was removed; use Query<Option<T>> fields or axum_extra::extract::OptionalQuery.

  • Sync handlers: handlers and services added to a Router must be Sync. A handler future holding a non-Sync value is fine; a handler type capturing, say, a Cell is not.

  • WebSockets: Message::Text carries Utf8Bytes and Message::Binary carries Bytes (was String and Vec<u8>); WebSocket::close was removed (send a Message::Close). Routing with any instead of get enables WebSockets over HTTP/2.

  • Host: the Host extractor moved to axum-extra, where it is now deprecated; read the Host header (or X-Forwarded-Host from a trusted proxy) yourself.

  • serve: axum::serve is generic over the listener; Serve::tcp_nodelay was replaced by serve::ListenerExt::tap_io.

  • Additions worth adopting: method_not_allowed_fallback, NoContent, Router::reset_fallback (0.8.4) and WebSocket subprotocol selection (0.8.9).

Upcoming in 0.9

The axum repository’s main branch lists breaking changes for 0.9 under "Unreleased"; there is no release date, and none of this applies to 0.8.9:

  • nested routers' fallbacks are merged into the outer router’s;

  • axum::serve applies a header-read timeout (closing the slow-loris gap described in Request Limits & Timeouts) and the output type of its future changes;

  • new serve::ListenerExt::limit_connections, Serve::with_executor, connection-age limits and support for the HTTP QUERY method.

Ecosystem upgrades that move together

Crate Versions What to change

tower-http

0.6 → 0.7

TimeoutLayer::new is deprecated for TimeoutLayer::with_status_code; new CsrfLayer and body deadline layers; ServeDir ETags and custom backends. Crates that still depend on 0.6 (tower_governor, leptos_axum) add a second copy (tower-http Catalog)

sqlx

0.8 → 0.9

non-literal SQL needs SqlSafeStr/AssertSqlSafe; MSRV 1.94; tower-sessions-sqlx-store still needs 0.8 (Databases with sqlx)

SeaORM

1.x → 2.0

new entity format with #[sea_orm::model], on sqlx 0.9 (SeaORM & Diesel)

jsonwebtoken

9 → 11

a crypto backend feature (aws_lc_rs or rust_crypto) is required; insecure helpers live under dangerous (JWT & Bearer Tokens)

utoipa

4/5 → 6

OpenAPI 3.2 output, utoipa-axum 0.3 and utoipa-swagger-ui 10 (OpenAPI with utoipa)

askama

0.12 → 0.13+

askama_axum removed; use askama_web or Html(template.render()?) (Server-side Templates)

OpenTelemetry

0.31 → 0.33

upgrade opentelemetry, the SDK, the exporter and tracing-opentelemetry 0.34 as one set (OpenTelemetry & Distributed Tracing)

tower-sessions / axum-login

0.14 / 0.18

axum-login 0.18 requires tower-sessions 0.14, not the current 0.15 (Sessions & Cookies)

Upgrade one row at a time, with cargo tree -d to spot duplicated versions, and keep Cargo.lock committed.

Outdated in the books

Area In the books → current

axum

axum::Server/hyper::Server and AddExtensionLayer (Crossley et al., chapter 15) → axum::serve and State/Extension; /:id, /*rest → /{id}, /{*rest}; #[async_trait] on extractors → native async fn; Option<T> extractors → OptionalFromRequestParts; TypedHeader and Host → axum-extra

tower-http

0.6 (Yoon; Gangappa & Rangaraj) → 0.7: TimeoutLayer::with_status_code, CsrfLayer, deadline layers

Databases

SeaORM 1.0 (Yoon, chapter 3) → 2.0; sqlx 0.8 (Gangappa & Rangaraj) → 0.9

Authentication

bcrypt (Yoon, chapter 4) → argon2, hashed in spawn_blocking; jsonwebtoken 9 → 11 with an explicit crypto provider

Templates

askama_axum → askama_web or Html(template.render()?)

OpenTelemetry

0.31 (Gangappa & Rangaraj) → 0.33 with tracing-opentelemetry 0.34

Docker

rust:1.73 images with .env copied in (Yoon, chapter 6) → a current rust builder, cargo-chef, a distroless non-root runtime and secrets at runtime (Deployment)

Practices

plain-text passwords and a JWT secret in a global (Yoon, chapters 3-4), CPU-heavy hashing inside async handlers, tokio::sync::Mutex as the default lock, unwrap() in handlers → the corrected forms on Password Authentication, State & Dependency Injection and Error Handling

Comparing Rust web frameworks

Framework Latest (2026-10-06) Character Choose it when

axum

0.8.9 (2026-04)

Tokio project; handlers are plain async functions, extractors are types, middleware is tower; no macros needed

you want the tower/hyper ecosystem, composable middleware and the largest share of current examples

actix-web

4.15.0 (2026-08)

its own actor-free runtime on top of Tokio (one single-threaded runtime per worker), routing macros, stable 4.x API since 2022

you value a long-stable API and its mature middleware set

Rocket

0.5.1 (2024-05)

batteries-included, macro-driven routing and request guards, typed forms, fairings

you want Rails-like conveniences and accept a slow release cadence

warp

0.4.3 (2026-05)

composable filters combined with and/or; strong typing, harder error messages

you like the filter style; few new projects choose it

poem

3.1.12 (2025-07)

axum-like handlers with an integrated OpenAPI layer (poem-openapi)

OpenAPI-first APIs with code generation from types

Loco

1.2.0 (2026-09)

a Rails-like framework on top of axum: generators, SeaORM models, mailers, background workers

a full-stack application with conventions rather than assembling crates yourself

The same ideas in other stacks this site covers:

Concept axum FastAPI NestJS Spring Boot ASP.NET Core minimal APIs

Handler

async fn

async def path operation

controller method

@RestController method

app.MapGet lambda

Input binding

extractors (Path, Json, State)

type hints + Pydantic

decorators + DTOs

@PathVariable, @RequestBody

parameter binding

Middleware

tower layers

ASGI middleware, dependencies

middleware, guards, interceptors

filters, interceptors

middleware pipeline

Dependency injection

State (explicit)

Depends

providers (container)

beans (container)

services (container)

Laravel follows the controller-and-container model of Spring Boot and NestJS. See FastAPI, NestJS, Spring Boot, ASP.NET Core and Laravel on this site. A broader comparison of backend and web frameworks across languages is planned (issue #263).