
Axum
- 20 installs
- 2 repo stars
- Updated May 4, 2026
- melonask/axum-skills
Helps with ai & agent building tasks.
About
axum is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- axum
- AI & Agent Building
- AI-coding skill
Axum by the numbers
- 20 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #10,450 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/melonask/axum-skills --skill axumAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 20 |
|---|---|
| repo stars | ★ 2 |
| Last updated | May 4, 2026 |
| Repository | melonask/axum-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Axum — Rust Web Framework
Axum is an ergonomic and modular web framework built on top of Tokio, Tower, and Hyper. It is maintained by the tokio-rs team and provides a first-class async experience for building HTTP services in Rust. Axum's design revolves around converting handler functions into services via traits like FromRequest and IntoResponse, making it both intuitive to use and deeply composable with the Tower middleware ecosystem. It supports HTTP/1 and HTTP/2 out of the box, WebSocket upgrades, Server-Sent Events, and integrates seamlessly with the broader tokio ecosystem.
Crate Architecture
| Crate | Version | Purpose |
|---|---|---|
axum | 0.8.9 | Main crate — routing, handlers, extractors (Path, Query, Json, Form, Bytes, WebSocket, Multipart), middleware, SSE, body types |
axum-core | 0.5.6 | Core traits (FromRequest, FromRequestParts, IntoResponse, IntoResponseParts), body types, error types, middleware primitives |
axum-extra | 0.12.6 | Extended extractors: CookieJar, SignedCookieJar, PrivateCookieJar, TypedHeader, Host, Either/Either3..8, OptionalQuery, Cached, JsonDeserializer, ErasedJson, JsonLines, WithRejection, TypedPath, AsyncReadBody, FileStream, Attachment, ErrorResponse |
axum-macros | 0.5.1 | Procedural macros: #[debug_handler], #[debug_middleware], TypedPath derive |
tower-http | 0.6.x | Production-ready middleware layers: CORS, tracing, compression, timeouts, auth, rate limiting, static file serving, request IDs |
tower | 0.5.x | Service trait, ServiceBuilder, ServiceExt — the middleware foundation axum builds upon |
tokio | 1.x | Async runtime, TCP listener, signal handling, file I/O |
hyper | 1.4+ | Low-level HTTP server/client (axum's HTTP implementation) |
matchit | 0.9.x | Path matching engine used by axum's router |
Quick Start: Minimal Dependency Setup
The minimal setup only requires axum and tokio. Default features include HTTP/1, JSON, form, query, matched-path, original-uri, tokio integration, and tracing:
# Cargo.toml
[dependencies]
axum = "0.8.9"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"For a production-ready setup with all common features enabled:
# Cargo.toml
[dependencies]
axum = { version = "0.8.9", features = [
"http1", # HTTP/1.1 support (default)
"http2", # HTTP/2 support
"json", # Json extractor/response (default)
"form", # URL-encoded form extractor (default)
"query", # Query string extractor (default)
"multipart", # Multipart form data
"ws", # WebSocket support
"macros", # #[debug_handler], #[debug_middleware]
] }
axum-extra = { version = "0.12.6", features = [
"typed-header", # TypedHeader extractor
"cookie", # CookieJar (unsigned)
"cookie-signed", # SignedCookieJar
"cookie-private", # PrivateCookieJar
"typed-routing", # TypedPath derive macro
"with-rejection", # WithRejection wrapper
] }
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6", features = [
"cors", "trace", "compression-gzip", "timeout", "limit", "fs",
] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }Feature Flags Reference
| Feature | Default | Description |
|---|---|---|
form | Yes | URL-encoded form body extractor |
http1 | Yes | HTTP/1.1 support via hyper |
json | Yes | JSON body extractor and Json<T> response |
matched-path | Yes | MatchedPath extractor |
original-uri | Yes | OriginalUri extractor |
query | Yes | Query string extractor |
tokio | Yes | tokio runtime integration, axum::serve |
tracing | Yes | tracing instrumentation on requests |
tower-log | Yes | Enable tower/log feature |
http2 | No | HTTP/2 support |
multipart | No | Multipart form data parsing |
ws | No | WebSocket support |
macros | No | #[debug_handler] and #[debug_middleware] macros |
Quick Reference: Which Guide Do I Need?
The user's task determines which reference file to read:
- Routing, path parameters, nested routers, fallbacks, method routing -> Read
references/routing.md - Request extractors (Path, Query, Json, Form, Bytes, State, Extension, WebSocket, Multipart, cookies) -> Read
references/extractors.md - Response types, IntoResponse, IntoResponseParts, custom response builders -> Read
references/responses.md - Middleware (tower layers, from_fn, from_fn_with_state, map_request/response) -> Read
references/middleware.md - State management, FromRef, shared application state patterns -> Read
references/state-management.md - Error handling, custom error types, WithRejection -> Read
references/error-handling.md - WebSocket, SSE, real-time communication -> Read
references/realtime.md - Multipart uploads, file handling, static file serving -> Read
references/files-uploads.md - Cookie management (plain, signed, private) -> Read
references/cookies.md - tower-http layers (CORS, tracing, compression, auth, rate limiting) -> Read
references/tower-http-layers.md - Testing, tower::ServiceExt, into_service, oneshot -> Read
references/testing.md - Migration from axum 0.7 to 0.8, breaking changes -> Read
references/migration-0.8.md
Core Patterns at a Glance
1. Build and Serve a Complete Application
This is the foundational pattern every axum application follows. The Router composes routes, with_state provides shared state, layer applies middleware, and axum::serve starts the server with built-in graceful shutdown support.
use axum::{
Router, serve,
routing::{get, post},
extract::{State, Path, Json},
response::IntoResponse,
http::StatusCode,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Clone, Debug, Serialize, Deserialize)]
struct User {
id: u64,
name: String,
}
#[derive(Clone)]
struct AppState {
users: Arc<RwLock<Vec<User>>>,
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let state = AppState {
users: Arc::new(RwLock::new(Vec::new())),
};
let app = Router::new()
.route("/", get(|| async { "Hello, World!" }))
.route("/users", get(list_users).post(create_user))
.route("/users/{id}", get(get_user).delete(delete_user))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
serve(listener, app).await.unwrap();
}
async fn list_users(State(state): State<AppState>) -> Json<Vec<User>> {
let users = state.users.read().await;
Json(users.clone())
}
async fn create_user(
State(state): State<AppState>,
Json(mut user): Json<User>,
) -> (StatusCode, Json<User>) {
let mut users = state.users.write().await;
user.id = users.len() as u64 + 1;
users.push(user.clone());
(StatusCode::CREATED, Json(user))
}
async fn get_user(
State(state): State<AppState>,
Path(id): Path<u64>,
) -> Result<Json<User>, (StatusCode, String)> {
let users = state.users.read().await;
users.iter()
.find(|u| u.id == id)
.cloned()
.map(Json)
.ok_or_else(|| (StatusCode::NOT_FOUND, "User not found".to_string()))
}
async fn delete_user(
State(state): State<AppState>,
Path(id): Path<u64>,
) -> StatusCode {
let mut users = state.users.write().await;
if let Some(pos) = users.iter().position(|u| u.id == id) {
users.remove(pos);
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}2. Authentication Middleware
Middleware in axum uses middleware::from_fn or middleware::from_fn_with_state. The middleware receives the full request, can inspect or modify it, call next.run(req).await to continue to the handler, and return a custom response to short-circuit.
use axum::{
Router, routing::get,
extract::{State, Request},
middleware::{self, Next},
response::IntoResponse,
http::StatusCode,
};
async fn auth_middleware(
State(state): State<AppState>,
mut req: Request,
next: Next,
) -> impl IntoResponse {
let auth_header = req
.headers()
.get(http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok());
match auth_header {
Some(token) if state.validate_token(token) => next.run(req).await,
_ => (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(),
}
}
let app = Router::new()
.route("/protected", get(protected_handler))
.route_layer(middleware::from_fn(auth_middleware))
.route("/public", get(public_handler)); // No auth required3. Custom Error Handling
Axum's error model relies on IntoResponse. Implement it for any custom error type, then return Result<T, YourError> from handlers. The ? operator propagates errors automatically since Result<T, E> implements IntoResponse when both T and E do.
use axum::response::{IntoResponse, Response};
use axum::http::StatusCode;
#[derive(Debug)]
enum AppError {
NotFound(String),
Unauthorized(String),
InternalError(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg),
AppError::InternalError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AppError::NotFound(msg) => write!(f, "Not found: {}", msg),
AppError::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg),
AppError::InternalError(msg) => write!(f, "Internal error: {}", msg),
}
}
}
impl std::error::Error for AppError {}
// Now use it in handlers:
async fn get_user(Path(id): Path<u64>) -> Result<Json<User>, AppError> {
let user = db::find_user(id).await
.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;
Ok(Json(user))
}4. WebSocket Real-Time Communication
WebSocket support requires the ws feature. Use WebSocketUpgrade as an extractor to upgrade HTTP connections. The handler returns the upgraded socket after calling ws.on_upgrade(). For HTTP/2 WebSocket support, register the route with .any() instead of .get().
use axum::{
extract::ws::{WebSocket, WebSocketUpgrade, Message},
response::IntoResponse,
};
async fn ws_handler(ws: WebSocketUpgrade) -> impl IntoResponse {
ws.on_upgrade(handle_socket)
}
async fn handle_socket(mut socket: WebSocket) {
while let Some(Ok(msg)) = socket.recv().await {
if socket.send(msg).await.is_err() {
break;
}
}
}
// Register with .any() to support WebSocket over HTTP/2
let app = Router::new().route("/ws", any(ws_handler));5. Server-Sent Events (SSE)
SSE provides a unidirectional stream from server to client. The Sse type wraps any Stream<Item = Result<Event, Error>>. Use KeepAlive to prevent connection drops.
use axum::response::sse::{Event, Sse, KeepAlive};
use std::convert::Infallible;
async fn sse_handler() -> Sse<impl futures_util::stream::Stream<Item = Result<Event, Infallible>> {
let stream = async_stream::stream! {
for i in 0..10 {
yield Ok(Event::default()
.data(format!("ping {}", i))
.event("message"));
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
};
Sse::new(stream).keep_alive(KeepAlive::default())
}6. CORS, Tracing, and tower-http Layers
Tower-http provides production-grade middleware as composable layers. Layers are applied via ServiceBuilder or directly with .layer(). Order matters — layers wrap the service, so the first .layer() call is the outermost layer.
use tower_http::{
cors::{CorsLayer, Any},
trace::TraceLayer,
compression::CompressionLayer,
};
use tower::ServiceBuilder;
let app = Router::new()
.route("/", get(handler))
.layer(
ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())
.layer(CompressionLayer::new()),
);Key Concepts
Extractor Categories
Axum extractors fall into two categories, and understanding the distinction is critical for writing correct handlers:
FromRequestParts — extract from request metadata (headers, path, query, state). These do NOT consume the request body and can appear in any order among themselves:
| Extractor | Source | Example |
|---|---|---|
Path<T> | URL path segments | Path(id): Path<u64> |
Query<T> | Query string | Query(params): Query<SearchParams> |
State<T> | Application state | State(db): State<DbPool> |
Extension<T> | Request extensions | Extension(user): Extension<User> |
HeaderMap | All headers | HeaderMap(headers): HeaderMap |
CookieJar | Cookies (axum-extra) | jar: CookieJar |
TypedHeader<T> | Single header (axum-extra) | TypedHeader(ua): TypedHeader<UserAgent> |
MatchedPath | Matched route pattern | MatchedPath(path) |
OriginalUri | Original request URI | OriginalUri(uri) |
FromRequest — extract from the request body. These CONSUME the body and must be the last extractor parameter:
| Extractor | Body Type | Example |
|---|---|---|
Json<T> | JSON | Json(data): Json<CreateUser> |
Form<T> | URL-encoded form | Form(data): Form<LoginForm> |
String | Raw text body | body: String |
Bytes | Raw bytes body | body: bytes::Bytes |
Multipart | Multipart form | multipart: Multipart |
Request | Full request with body | req: extract::Request |
Body | Raw axum body | body: axum::body::Body |
WebSocketUpgrade | WebSocket upgrade | ws: WebSocketUpgrade |
Middleware Layer Stack
Understanding the layering order is essential. Tower layers wrap the service from outside-in. The first layer applied is the outermost — it sees the request first and the response last:
Incoming Request
|
v
TraceLayer (logs request, starts span)
|
v
CorsLayer (adds CORS headers)
|
v
CompressionLayer (decompresses request)
|
v
AuthMiddleware (from_fn: validates token)
|
v
Handler (your route handler)
|
v
CompressionLayer (compresses response)
|
v
CorsLayer (adds CORS headers to response)
|
v
TraceLayer (logs response, ends span)
|
v
Outgoing ResponseState Management Patterns
The State<T> extractor provides type-safe access to shared application state. The state type must implement Clone because axum clones it for each request. For complex state, use Arc internally to avoid expensive cloning.
#[derive(Clone)]
struct AppState {
db: Arc<DbPool>, // Arc for shared ownership
config: AppConfig, // Small types can be Clone directly
}
// FromRef lets you extract sub-parts of state
#[derive(Clone)]
struct AppState {
db: DbPool,
cache: RedisClient,
}
impl FromRef<AppState> for DbPool {
fn from_ref(state: &AppState) -> Self {
state.db.clone()
}
}
impl FromRef<AppState> for RedisClient {
fn from_ref(state: &AppState) -> Self {
state.cache.clone()
}
}
// Now extract individual fields directly:
async fn handler(State(db): State<DbPool>, State(cache): State<RedisClient>) { }The #[derive(FromRef)] macro (from axum's macros feature) can auto-generate these implementations for struct fields.
Common Pitfalls
1. Body-consuming extractors must be last — Json, String, Bytes, Form, Multipart, and Request consume the body. Place them as the final parameter in your handler function signature. If you put a FromRequestParts extractor after a body extractor, it will fail at compile time or runtime.
2. State must be `Clone + Send + Sync` — axum clones the state type for each request via .with_state(state). Wrap expensive-to-clone fields in Arc or Arc<RwLock<>> so the clone is cheap. The type itself must be Send + Sync because handlers run on Tokio's thread pool.
3. All handlers must be `Send + Sync` (0.8+) — axum 0.8 requires that handler functions and any captured state are Send + Sync. If you use Rc, RefCell, or other non-thread-safe types, the code will not compile. Use Arc and RwLock/Mutex instead.
4. Path syntax changed in 0.8 — Use {id} instead of :id, and {*path} instead of *path. The old colon syntax no longer compiles. This was driven by an upgrade to matchit 0.8/0.9.
5. Don't double-nest at the same path — .nest("/api", a).nest("/api", b) will panic at runtime. Instead, merge the routers first: let combined = a.merge(b); app.nest("/api", combined).
6. Default body limit is 2MB — Json, Form, and Multipart extractors reject bodies larger than 2MB by default. For file uploads or large payloads, increase the limit with DefaultBodyLimit::max() or disable it with DefaultBodyLimit::disable().
7. WebSocket over HTTP/2 needs `.any()` — When using HTTP/2 (the http2 feature), WebSocket upgrade requests are not sent as GET requests. Register WebSocket handlers with .any(ws_handler) instead of .get(ws_handler) to ensure compatibility with both HTTP/1 and HTTP/2.
8. `Option<T>` behavior changed in 0.8 — Option<Path<T>> now rejects the request if path segments exist but fail to parse (instead of silently returning None). Use OptionalFromRequestParts/OptionalFromRequest implementations for fine-grained control.
9. `#[async_trait]` is no longer needed — Custom extractors and middleware in 0.8 use native impl Future in traits instead of the #[async_trait] macro. Remove async_trait from your implementations when upgrading from 0.7.
10. Layer ordering is outside-in — The first .layer() call wraps the outermost layer. For ServiceBuilder, layers are applied in order (first listed = outermost). Think carefully about whether your auth middleware should see requests before or after decompression.
Reference Files
For detailed information on any topic, read the appropriate reference file:
references/routing.md— Route definition, path parameters (new{id}syntax), wildcard{*path}, nested routers,nestvsmerge,fallback,method_not_allowed_fallback,route_service,nest_service,MethodRouterchaining, method-specific routing,CONNECTmethodreferences/extractors.md— All built-in extractors (Path, Query, Json, Form, Bytes, String, State, Extension, HeaderMap, MatchedPath, OriginalUri, ConnectInfo, WebSocketUpgrade, Multipart, Request, Body), axum-extra extractors (CookieJar, SignedCookieJar, PrivateCookieJar, TypedHeader, Host, Either/Either3..8, OptionalQuery, Cached, JsonDeserializer, WithRejection), custom extractor implementation, FromRequestParts vs FromRequest,Option<T>andResult<T, E>extractor patternsreferences/responses.md— IntoResponse trait and all implementations (String, Json, Html, StatusCode, tuples, Sse, WebSocketUpgrade, Redirect, NoContent), IntoResponseParts for headers/cookies, AppendHeaders, custom IntoResponse implementations, response builder patternsreferences/middleware.md—middleware::from_fn,middleware::from_fn_with_state,middleware::map_request,middleware::map_response, applying middleware to specific routes withroute_layer, layer on Router vs MethodRouter, request/response transformation, composing multiple middleware, custom middleware patternsreferences/state-management.md—Router::with_state,State<T>extractor,FromReftrait,#[derive(FromRef)]macro,Extension<T>for runtime injection, sharing state across middleware and handlers, state lifetime patterns withArc,Arc<RwLock<>>,Arc<Mutex<>>references/error-handling.md— Custom error types with IntoResponse, Result<T, E> in handlers, BoxError,#[derive(Debug)]patterns, rejection types,WithRejectionwrapper, error propagation with?, combining multiple error types, axum-extraErrorResponsereferences/realtime.md— WebSocket setup and configuration (max_frame_size, max_send_queue, write_buffer_size), WebSocket message types (Text with Utf8Bytes, Binary with Bytes), WebSocket over HTTP/2, Server-Sent Events (SSE) with Event streams, KeepAlive, JSON data in SSE, binary SSE data, broadcast patterns with tokio::sync::broadcastreferences/files-uploads.md— Multipart form handling, field iteration (name, file_name, content_type, bytes, text, chunk), saving uploaded files,DefaultBodyLimitfor upload size, static file serving withServeDirandServeFile, SPA fallback patterns,tower_http::services::ServeDirreferences/cookies.md— CookieJar (unsigned), SignedCookieJar (HMAC-signed), PrivateCookieJar (AES-encrypted), cookie Key generation and management,FromReffor Key, reading/writing/removing cookies, cookie options (path, domain, secure, httponly, max-age, same-site)references/tower-http-layers.md— Complete reference for all tower-http layers: CorsLayer (permissive and restrictive), TraceLayer, CompressionLayer/DecompressionLayer, RequestBodyLimitLayer, TimeoutLayer, SetRequestIdLayer, PropagateHeaderLayer, SensitiveHeaderLayer, CatchPanicLayer, AuthLayer/RequireAuthorizationLayer, MetricsLayer, NormalizePathLayer, SetHeaderLayer, SetStatusLayer, RedirectLayer, ServeDir/ServeFilereferences/testing.md—tower::ServiceExt::oneshotfor unit testing,Router::into_service, building test requests, asserting response status and body, integration testing patterns, test state setupreferences/migration-0.8.md— Complete migration guide from axum 0.7 to 0.8: path syntax changes, Host extractor move, WebSocket Message type changes, Option<T> behavior, Sync requirement, removed APIs, new APIs (method_not_allowed_fallback, NoContent, WebSocket over HTTP/2, CONNECT method)
Axum Skill Known Issues
This file documents real-world bugs confirmed while testing the skill against the latest crates as of April 2026.
Environment
- axum 0.8.9
- axum-core 0.5.6
- axum-extra 0.12.6
- axum-macros 0.5.1
- tower-http 0.6.8
- tower 0.5.3
- tokio 1.52.1
- hyper 1.9.0
- cookie 0.18.1
- matchit 0.8.6
---
Issue 1: Legacy path syntax (:id, /*path) in multiple reference files
Files affected:
references/routing.mdreferences/extractors.mdreferences/testing.mdreferences/middleware.mdreferences/state-management.mdreferences/migration-0.8.md
What the skill says: .route("/users/:id", get(handler))
What happens when copied:
- Compile succeeds but runtime panic on startup:
Path segments must not start with :. For capture groups, use {capture}. - Catch-all
.route("/*path", ...)panics with:Path segments must not start with *. For wildcard capture, use {*wildcard}.
Fix: Replace all occurrences with new syntax: :param → {param}, /*path → {*path}.
---
Issue 2: middleware.md — next.run() called with wrong arguments
Files affected:
references/middleware.mdreferences/testing.mdreferences/state-management.md
What the skill says: next.run(headers).await, next.run(()).await, and next.run(()).await in multiple snippets.
What happens when copied: Compile error: expected Request<Body>, found HeaderMap or expected Request<Body>, found ().
Fix: Change all middleware to take req: Request<Body> (or req: Request) as the first parameter and call next.run(req).await.
---
Issue 3: middleware.md — next.into_request() does not exist
What the skill says: let mut req = next.into_request(); inside the custom middleware with extractors example.
What happens when copied: Compile error: no method named into_request found for struct Next
Fix: Take req: Request<Body> as parameter directly, then do req.extensions_mut().insert(user); next.run(req).await.
---
Issue 4: references/responses.md — wrong module path for Event
What the skill says: axum::extract::Event or axum::response::Event inside SSE snippets.
What happens when copied: Compile error: cannot find type Event in module axum::extract
Fix: Event lives in axum::response::sse. Replace with axum::response::sse::Event.
---
Issue 5: references/tower-http-layers.md — wrong CompressionLevel import
What the skill says: use tower_http::compression::{CompressionLayer, compression::CompressionLevel};
What happens when copied: Compile error: could not find compression in compression
Fix: CompressionLevel is re-exported directly as tower_http::compression::CompressionLevel.
---
Issue 6: references/tower-http-layers.md — RequireAuthorizationLayer does not exist
What the skill says: Uses tower_http::auth::RequireAuthorizationLayer::basic and ::bearer.
What happens when copied: Compile error: could not find RequireAuthorizationLayer in auth
Fix: In tower-http 0.6, only AsyncRequireAuthorizationLayer exists. Provide an example using AsyncAuthorizeRequest trait + AsyncRequireAuthorizationLayer::new(...).
---
Issue 7: references/cookies.md — outdated axum-extra version
What the skill says: axum-extra = { version = "0.10", features = [...] }
Impact: Mismatched with SKILL.md which correctly recommends 0.12.6. Copying this Cargo.toml snippet alone would downgrade dependencies and possibly introduce compatibility issues.
Fix: Change version to 0.12.6.
---
Issue 8: SKILL.md — SSE snippet uses futures_core::stream::Stream without dependency note
What the skill says: impl futures_core::stream::Stream<Item = Result<Event, Infallible>>
Impact: futures_core is not in the skill's quick-start Cargo.toml snippet. Most users only have futures-util. Code won't compile without adding futures-core = "0.3".
Fix: Replace with futures_util::stream::Stream (same trait, re-exported) or add futures-core to the snippet.
---
Issue 9: migration-0.8.md — Host feature version also outdated
What the skill says: axum-extra = { version = "0.10", features = ["host"] }.
Fix: Change to 0.12 (or 0.12.6).
---
Issue 10: extractors.md custom extractor uses BoxFuture needlessly
What the skill says: Custom extractor example still uses BoxFuture and Box::pin for from_request_parts.
Impact: While the code compiles, it requires futures crate dependency. In axum 0.8, from_request_parts natively returns impl Future, so async fn works directly without BoxFuture.
Fix: Rewrite example with async fn from_request_parts(...).
---
Issue 11: references/cookies.md — cookie::Duration is private
What the skill says: use cookie::{Cookie, SameSite, Duration, time::OffsetDateTime};
What happens when copied: Compile error: struct Duration is private
Fix: Duration is re-exported from the time crate. Use use cookie::time::Duration; instead.
Also: CookieBuilder::finish() is deprecated in cookie 0.18+; prefer CookieBuilder::build().
---
Issue 12: references/tower-http-layers.md — TimeoutLayer::new is deprecated
What the skill says: .layer(TimeoutLayer::new(Duration::from_secs(3)))
What happens when copied: Deprecation warning: use of deprecated associated function tower_http::timeout::TimeoutLayer::new
Fix: Use TimeoutLayer::with_status_code.
---
Issue 13: references/tower-http-layers.md — SetHeaderLayer module path changed
What the skill says: use tower_http::{set_header::SetHeaderLayer, set_status::SetStatusLayer};
What happens when copied: Compile error for SetHeaderLayer.
Fix: SetHeaderLayer lives in tower_http::set_header. Verify set-header feature is enabled.
---
Issue 14: references/files-uploads.md — MultipartError does not exist in axum_extra::extract
What the skill says: use axum_extra::extract::MultipartError;
What happens when copied: Compile error: no MultipartError in extract
Fix: Remove this import; use axum::extract::multipart::MultipartError or handle errors via Result.
---
Issue 15: references/files-uploads.md — field.file_name() returns Option<&str>, not Option<String>
What the skill says: let _name: Option<String> = field.file_name();
What happens when copied: Compile error: mismatched types: expected Option<String>, found Option<&str>
Fix: field.file_name() returns Option<&str>. Call .map(|s| s.to_string()) if you need an owned String.
---
Issue 16: references/testing.md — StatusCode::NOT_FOUND.into_response() doesn't compile without import
What the skill says: axum::http::StatusCode::NOT_FOUND.into_response() inside handler.
What happens when copied: Compile error: no method named into_response found for struct StatusCode
Fix: Import axum::response::IntoResponse.
---
Issue 17: references/migration-0.8.md — old * catch-all syntax causes panic
What the skill says: .route("/files/*path", get(serve_file)) in the "Before" section.
What happens when copied: The snippet is intentionally "Before", but if a user copies it, they get a runtime panic.
Fix: Already labeled "Before", but add a clearer warning that this syntax will panic in 0.8.
---
Issue 18: references/middleware.md — HeaderMap extractor in middleware without Request parameter
What the skill says:
async fn auth_middleware(
headers: axum::http::HeaderMap,
next: middleware::Next,
) -> Result<impl IntoResponse, AuthError> {What happens when copied: Compile error because HeaderMap is a FromRequestParts extractor that can't be used alone in from_fn without Request.
Fix: Change to take req: Request<Body> and read req.headers() inside the function.
---
Issue 19: axum_extra::extract::Host is deprecated in axum-extra 0.12.6
What the skill says: Using Host extractor for hostname extraction.
What happens when copied: Deprecation warning:
use of deprecated tuple struct `axum_extra::extract::Host`: will be removed in the next versionFix: Use http::HeaderMap and manually read the Host header, or watch for the next axum-extra release for the replacement.
---
Test Artifacts
All issues were verified in the /tmp/axum-skill-test Rust project against real crates as of April 2026. The project runs 6+ unit tests and a full integration test suite against a real axum server covering routing, extractors, error handling, WebSocket, SSE, cookies, form handling, file downloads, authentication middleware, and custom response headers.
axum-skills
A comprehensive skill for building web applications and APIs with axum (by tokio-rs). This skill enables LLM developers to build production-ready HTTP services in Rust using axum's ergonomic handler-based API, its deep integration with the Tower middleware ecosystem, and the full power of async Rust via Tokio.
Overview
This skill provides complete guidance for every major feature of the axum framework (version 0.8.x), including:
- Routing — Path parameters, nested routers, wildcard routes, fallbacks, method routing, route merging
- Extractors — Path, Query, Json, Form, Bytes, String, State, Extension, HeaderMap, WebSocket, Multipart, cookies, TypedHeader, Either/N types, and custom extractors
- Responses — IntoResponse trait, IntoResponseParts, status codes, headers, HTML, JSON, streaming, redirects
- Middleware —
middleware::from_fn,from_fn_with_state,map_request,map_response, tower layers, per-route middleware - State Management — Shared application state, FromRef, Extension, Arc patterns
- Error Handling — Custom error types, rejection types, WithRejection, BoxError
- Real-Time — WebSocket (HTTP/1 and HTTP/2), Server-Sent Events (SSE), broadcast patterns
- File Handling — Multipart uploads, static file serving, SPA fallback
- Cookies — Plain, signed (HMAC), and private (AES-encrypted) cookie management
- Production Layers — CORS, tracing, compression, timeouts, rate limiting, auth, request IDs via tower-http
- Testing — Unit and integration testing with tower::ServiceExt
- Migration — Upgrading from axum 0.7 to 0.8
Installation
npx skills add melonask/axum-skillsFile Structure
axum/
├── SKILL.md # Core skill instructions (entry point)
├── README.md # This file — skill overview and installation
└── references/ # Deep-dive guides loaded on demand
├── routing.md # Route definition, path params, nesting, fallbacks
├── extractors.md # All extractors (axum + axum-extra)
├── responses.md # IntoResponse, IntoResponseParts, response builders
├── middleware.md # from_fn, from_fn_with_state, map_request/response
├── state-management.md # State, FromRef, Extension, Arc patterns
├── error-handling.md # Custom errors, rejections, WithRejection
├── realtime.md # WebSocket, SSE, broadcast patterns
├── files-uploads.md # Multipart uploads, static file serving
├── cookies.md # CookieJar, SignedCookieJar, PrivateCookieJar
├── tower-http-layers.md # All tower-http middleware layers
├── testing.md # Unit and integration testing patterns
└── migration-0.8.md # Migration guide from axum 0.7 to 0.8Quick Start
# Cargo.toml
[dependencies]
axum = "0.8.9"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"use axum::{Router, routing::get, serve};
let app = Router::new().route("/", get(|| async { "Hello, World!" }));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
serve(listener, app).await.unwrap();Compatible Versions
| Crate | Version |
|---|---|
axum | 0.8.x (latest: 0.8.9) |
axum-core | 0.5.x (latest: 0.5.6) |
axum-extra | 0.12.x (latest: 0.12.6) |
axum-macros | 0.5.x (latest: 0.5.1) |
tower-http | 0.6.x |
tower | 0.5.x |
tokio | 1.x |
hyper | 1.4+ |
How It Works
The skill uses a progressive disclosure architecture:
1. SKILL.md (always loaded) — Provides crate architecture, quick-start setup, feature flags, 6 core code patterns, key concepts, and a routing table pointing to reference files 2. *references/\.md** (loaded on demand) — When a user's request involves a specific area (e.g., middleware, cookies, WebSocket), the LLM reads the corresponding reference file for in-depth guidance with additional code examples and edge cases
This keeps the initial context small while making detailed information available when needed.
Key Design Principles
- Handler-first: Axum converts ordinary async functions into HTTP handlers via trait implementations (
FromRequest,IntoResponse) - Tower-native: Every axum
Routeris atower::Service, so any tower middleware works without adapters - Type-safe: Path parameters, query strings, and request bodies are deserialized into typed structs via serde
- Ergonomic: Extractors as function parameters, tuple responses for status + body, automatic error propagation
- Zero-cost abstractions: No runtime overhead beyond what Tokio and Hyper already provide
License
This skill is provided as-is for educational and development purposes. axum itself is licensed under the MIT License.
Axum Cookie Management Reference (axum 0.8.x)
Dependency Setup
Add axum-extra with the appropriate cookie feature flags to Cargo.toml. The cookie crate is re-exported through axum-extra — you do not add it separately.
[dependencies]
axum = "0.8"
axum-extra = { version = "0.12.6", features = ["cookie", "cookie-signed", "cookie-private"] }
tower-cookies = "0.11" # required by axum-extra cookie features| Feature flag | Enables |
|---|---|
cookie | CookieJar — unsigned, plain-text cookies |
cookie-signed | SignedCookieJar — HMAC-signed cookies |
cookie-private | PrivateCookieJar — AES-encrypted cookies |
Enable only the features you actually use. Each adds a dependency: cookie-signed pulls in hmac + sha2; cookie-private pulls in aes-gcm.
---
Key Generation
Signed and private cookie jars require a cryptographic key. Generate one at application startup and never hardcode it.
use axum_extra::extract::cookie::Key;
// Generate a random 64-byte key (suitable for both signed and private jars).
let key = Key::generate();
// Persist / restore a key from a base64 string (e.g. loaded from env/config).
let key = Key::from(
base64::Engine::decode(
&base64::engine::general_purpose::URL_SAFE,
"your_base64_encoded_64_byte_key_here__________",
)
.expect("valid base64"),
);
// Derive the key from application state via FromRef (see Complete Example below).Why 64 bytes? The key is split in half: the first 32 bytes feed the HMAC signing key, the remaining 32 bytes feed the AES-GCM encryption key. This single key works for both SignedCookieJar and PrivateCookieJar.
---
CookieJar (Unsigned)
CookieJar reads and writes plain-text cookies with no cryptographic protection. Use it for non-sensitive preferences like theme selection or language.
Why unsigned? Plain cookies are the smallest and fastest to process. They are appropriate when the client can safely know (and modify) the value.
use axum::{extract::FromRequestParts, http::request::Request, response::IntoResponseParts};
use axum_extra::extract::CookieJar;
// --- Reading cookies ---
async fn read_theme(jar: CookieJar) -> String {
// .get() returns Option<Cookie>
jar.get("theme")
.map(|c| c.value().to_owned())
.unwrap_or_else(|| "light".into())
}
// --- Adding / updating cookies ---
async fn set_theme(jar: CookieJar) -> impl IntoResponseParts {
let cookie = jar
.add(
cookie::Cookie::build(("theme", "dark"))
.path("/")
.http_only(true)
.max_age(cookie::Duration::days(365))
.finish(),
);
// IntoResponseParts lets you return cookies alongside any body response.
(cookie, "Theme set")
}
// --- Removing cookies ---
async fn clear_theme(jar: CookieJar) -> impl IntoResponseParts {
let cookie = jar.remove(cookie::Cookie::from("theme"));
(cookie, "Theme cleared")
}CookieJar as Extractor
CookieJar is an extractor — it pulls cookies from the incoming Cookie header. It also implements IntoResponseParts, so returning it (or the result of jar.add() / jar.remove()) sets the Set-Cookie header on the response.
Why separate extractor and response? Axum's architecture treats request extraction and response modification as distinct phases. CookieJar participates in both: extraction reads Cookie, IntoResponseParts writes Set-Cookie.
---
SignedCookieJar (HMAC-Signed)
Signed cookies are tamper-evident but not encrypted. The value is visible to the client but any modification invalidates the HMAC signature.
Why sign? Signing prevents clients from forging cookie values. A session ID or user role stored in a signed cookie cannot be tampered with.
use axum_extra::extract::CookieJar;
use axum_extra::extract::cookie::SignedCookieJar;
async fn login(mut jar: SignedCookieJar) -> impl IntoResponseParts {
// The jar must be mutable to add/remove cookies.
let jar = jar.add(cookie::Cookie::new("session_id", "abc123"));
(jar, "Logged in")
}
async fn read_session(jar: SignedCookieJar) -> String {
// Returns None if the signature is invalid or the cookie is missing.
jar.get("session_id")
.map(|c| c.value().to_owned())
.unwrap_or_else(|| "no session".into())
}
async fn logout(mut jar: SignedCookieJar) -> impl IntoResponseParts {
let jar = jar.remove(cookie::Cookie::from("session_id"));
(jar, "Logged out")
}How it works: The value is serialized as <base64_hmac>.<base64_value>. The server recomputes the HMAC on each request; mismatched signatures cause the jar to ignore the cookie silently (returns None).
---
PrivateCookieJar (AES-Encrypted)
Private cookies are both signed and encrypted. The value is unreadable to the client and tampering is detected.
Why encrypt? Use private cookies for sensitive data such as auth tokens, personal preferences, or anything the client must not see.
use axum_extra::extract::CookieJar;
use axum_extra::extract::cookie::PrivateCookieJar;
async fn save_token(mut jar: PrivateCookieJar) -> impl IntoResponseParts {
let jar = jar.add(cookie::Cookie::new("auth_token", "super_secret_jwt"));
(jar, "Token saved")
}
async fn read_token(jar: PrivateCookieJar) -> String {
jar.get("auth_token")
.map(|c| c.value().to_owned())
.unwrap_or_else(|| "no token".into())
}How it works: The value is encrypted with AES-256-GCM, then base64-encoded. The Key provides both the HMAC and AES sub-keys. Decryption fails silently on tampered cookies, returning None.
---
Cookie Options
Control cookie behavior via Cookie::build():
use cookie::{Cookie, SameSite, Duration, time::OffsetDateTime};
let cookie = Cookie::build(("user_pref", "en"))
.path("/") // sent on every path (default is current path)
.domain("example.com") // restrict to this domain (and subdomains)
.secure(true) // only sent over HTTPS
.http_only(true) // invisible to JavaScript
.max_age(Duration::days(30)) // expires in 30 days
.same_site(SameSite::Strict) // CSRF protection
.expires( // absolute expiry (alternative to max_age)
OffsetDateTime::now_utc() + Duration::days(30),
)
.finish();| Option | Purpose |
|---|---|
path | URL path prefix the cookie is sent for |
domain | Domain the cookie belongs to (use carefully — avoid ".") |
secure | Cookie is only sent over HTTPS |
http_only | Cookie is not accessible via document.cookie |
max_age | Relative lifetime in seconds |
expires | Absolute expiry OffsetDateTime |
same_site | Strict, Lax, or None — controls cross-site sending |
Why set `secure` and `http_only`? secure prevents cookies from leaking over plaintext HTTP. http_only blocks XSS attacks from stealing cookies. Always set both for auth-related cookies.
---
Complete Working Example
This demonstrates all three jar types, Key management, and FromRef.
use axum::{
Router,
routing::{get, post},
extract::FromRef,
};
use axum_extra::extract::{CookieJar, cookie::{Key, SignedCookieJar, PrivateCookieJar}};
#[derive(Clone)]
struct AppState {
key: Key,
}
// Derive Key from AppState so the signed/private jars can extract it.
impl FromRef<AppState> for Key {
fn from_ref(state: &AppState) -> Self {
state.key.clone()
}
}
#[tokio::main]
async fn main() {
let key = Key::generate();
let state = AppState { key };
let app = Router::new()
.route("/", get(read_all))
.route("/set", post(set_all))
.route("/clear", post(clear_all))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn read_all(
jar: CookieJar,
signed: SignedCookieJar,
private: PrivateCookieJar,
) -> String {
format!(
"plain theme={:?} | signed session={:?} | private token={:?}",
jar.get("theme").map(|c| c.value().to_string()),
signed.get("session_id").map(|c| c.value().to_string()),
private.get("auth_token").map(|c| c.value().to_string()),
)
}
async fn set_all(
jar: CookieJar,
mut signed: SignedCookieJar,
mut private: PrivateCookieJar,
) -> impl axum::response::IntoResponse {
let jar = jar.add(cookie::Cookie::new("theme", "dark"));
let signed = signed.add(cookie::Cookie::new("session_id", "user_42"));
let private = private.add(cookie::Cookie::new("auth_token", "jwt_payload"));
(jar, signed, private, "All cookies set")
}
async fn clear_all(
jar: CookieJar,
mut signed: SignedCookieJar,
mut private: PrivateCookieJar,
) -> impl axum::response::IntoResponse {
let jar = jar.remove(cookie::Cookie::from("theme"));
let signed = signed.remove(cookie::Cookie::from("session_id"));
let private = private.remove(cookie::Cookie::from("auth_token"));
(jar, signed, private, "All cookies cleared")
}Why `FromRef`? SignedCookieJar and PrivateCookieJar need a Key to verify/decrypt. FromRef bridges your application state to the extractor system, so Axum can automatically pull the key out of your state type.
---
Cookie Patterns
Session Management with Signed Cookies
Store a server-issued session ID in a signed cookie. The signature prevents clients from forging session IDs.
async fn create_session(mut jar: SignedCookieJar) -> impl IntoResponseParts {
let session_id = uuid::Uuid::new_v4().to_string();
let cookie = cookie::Cookie::build(("sid", session_id))
.http_only(true)
.secure(true)
.same_site(cookie::SameSite::Strict)
.max_age(cookie::Duration::hours(24))
.finish();
let jar = jar.add(cookie);
(jar, "Session created")
}User Preferences with Plain Cookies
Theme, language, and other non-sensitive choices go in unsigned cookies.
async fn set_lang(jar: CookieJar) -> impl IntoResponseParts {
let jar = jar.add(
cookie::Cookie::build(("lang", "ja"))
.path("/")
.max_age(cookie::Duration::days(365))
.finish(),
);
(jar, "Language set")
}Sensitive Data with Private Cookies
Auth tokens, feature flags tied to paid tiers, or personal data should be encrypted so the client cannot read or forge them.
async fn store_api_key(mut jar: PrivateCookieJar) -> impl IntoResponseParts {
let jar = jar.add(
cookie::Cookie::build(("api_key", "sk_live_abc123"))
.http_only(true)
.secure(true)
.path("/api")
.finish(),
);
(jar, "API key stored securely")
}Cookie-Based Authentication
Combine a signed session cookie with middleware or per-handler checks.
async fn protected_route(signed: SignedCookieJar) -> String {
match signed.get("user_id") {
Some(c) => format!("Welcome, {}", c.value()),
None => "Unauthorized".to_string(),
}
}Cookie Consent Handling
Track whether the user has accepted cookies before setting non-essential ones.
async fn set_analytics(jar: CookieJar) -> impl axum::response::IntoResponse {
let consented = jar.get("consent")
.map(|c| c.value() == "yes")
.unwrap_or(false);
if consented {
let jar = jar.add(cookie::Cookie::new("analytics_id", "ga_123"));
(jar, "Analytics enabled").into_response()
} else {
"Consent required".into_response()
}
}---
Signed vs Private: When to Use Which
| Concern | CookieJar (unsigned) | SignedCookieJar | PrivateCookieJar |
|---|---|---|---|
| Client can read | Yes | Yes | No |
| Client can forge | Yes | No | No |
| Client can modify | Yes | No | No |
| CPU overhead | None | Low (HMAC) | Medium (AES-GCM) |
| Cookie size overhead | None | ~40 bytes | ~50 bytes |
| Key required | No | Yes | Yes |
| Use for | Preferences, consent | Session IDs, roles | Tokens, PII |
Rule of thumb: Use CookieJar for data the client may freely read and change. Use SignedCookieJar when the client may read but not write the value. Use PrivateCookieJar when the client must not read the value at all.
---
Common Pitfalls
1. Forgetting `FromRef` — SignedCookieJar and PrivateCookieJar require a Key from state. If you call .with_state(state) but do not implement FromRef<AppState> for Key, extraction panics at runtime with a clear error.
2. Key mismatch across restarts — If you regenerate the key on every process restart, all existing signed/private cookies become invalid. Persist the key (env var, config file, secret manager) and reuse it across deployments.
3. Mutability — jar.add() and jar.remove() consume the jar and return a new one. You must use mut jar (or rebind the variable) to chain additions.
4. Multiple jars in one handler — You can extract all three jar types in the same handler. Axum reads the Cookie header once and distributes it to each jar. On the response side, each jar's Set-Cookie headers are combined.
5. Cookie size limits — Browsers enforce a 4 KB per-cookie limit. Encrypted cookies are ~33% larger than plain values due to base64 encoding. Do not store large payloads in cookies; store an ID and look up server-side data.
6. `domain` edge case — Setting domain("example.com") makes the cookie available to sub.example.com too. Omit domain to restrict to the exact origin. Never set domain(".example.com") — the leading dot is deprecated.
7. `SameSite` defaults — Modern browsers default to Lax. If you need cross-site cookies (e.g., OAuth callbacks), you must explicitly set same_site(SameSite::None) and also set secure(true).
Axum Error Handling Reference (axum 0.8.x)
1. Result<T, E> in Handlers
Axum handlers return impl IntoResponse. Because Result<T, E> has a blanket IntoResponse impl when both T and E implement IntoResponse, handlers can return Result<impl IntoResponse, impl IntoResponse> and use ? to propagate errors naturally.
use axum::{response::IntoResponse, http::StatusCode, Json};
use serde_json::{json, Value};
async fn handler() -> Result<Json<Value>, StatusCode> {
let data = fetch_data().await?;
Ok(Json(json!({ "result": data })))
}
async fn fetch_data() -> Result<String, StatusCode> {
Err(StatusCode::INTERNAL_SERVER_ERROR) // ? propagates this up
}Why this works: Axum provides impl IntoResponse for Result<T, E>. When the handler returns Ok(value), axum calls value.into_response(). When it returns Err(error), axum calls error.into_response(). This means any type that implements IntoResponse can be used on either side of a Result.
2. Custom Error Types
Implement IntoResponse on your own enums or structs to produce rich error responses. This lets you control both the HTTP status code and the response body.
use axum::{response::{IntoResponse, Response}, http::StatusCode, Json};
use serde_json::json;
enum AppError {
NotFound(String),
Unauthorized,
Internal(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".into()),
AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
};
(status, Json(json!({ "error": message }))).into_response()
}
}
// Now handlers return Result<Json<Value>, AppError>
async fn get_user() -> Result<Json<serde_json::Value>, AppError> {
Err(AppError::NotFound("user not found".into()))
}Why implement IntoResponse directly? It gives full control over the status code and body in one place. The alternative is wrapping every error in a tuple (StatusCode, Json<…>) at each call site, which is repetitive and error-prone.
3. Implementing Display and Error
When you implement std::error::Error, the trait requires Display. Both are needed because:
- `Display` — provides the human-readable message; used by
anyhow,tracing, and any code that formats the error. - `Error` — marks the type as an error in the Rust ecosystem; enables
source()chaining,Box<dyn Error>, andanyhowcompatibility.
use std::fmt;
#[derive(Debug)]
enum AppError {
NotFound(String),
Database(sqlx::Error),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::NotFound(msg) => write!(f, "not found: {msg}"),
AppError::Database(e) => write!(f, "database error: {e}"),
}
}
}
impl std::error::Error for AppError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
AppError::NotFound(_) => None,
AppError::Database(e) => Some(e), // lets callers inspect the inner error
}
}
}4. Rejection Types
Every extractor (except Extension) produces a _rejection_ type when it fails. These rejections implement IntoResponse, so unhandled extraction failures still produce a valid HTTP response—but the default body is usually a plain string.
use axum::{
extract::{Path, Query, Json},
http::StatusCode,
response::IntoResponse,
};
use serde::Deserialize;
// PathRejection when path params don't parse
async fn user_path(Path(id): Path<u32>) -> String { format!("{id}") }
// QueryRejection when query string is missing or malformed
#[derive(Deserialize)]
struct Params { page: u32 }
async fn list(Query(p): Query<Params>) -> String { format!("{}", p.page) }
// JsonRejection when body is invalid JSON or wrong shape
#[derive(Deserialize)]
struct CreateUser { name: String }
async fn create(Json(user): Json<CreateUser>) -> String { user.name }
// Catch and customize rejections by returning Result:
async fn safe_create(Json(user): Json<CreateUser>) -> Result<String, (StatusCode, String)> {
// If Json extraction fails, axum returns Err(JsonRejection as (StatusCode, String))
Ok(user.name)
}Why rejections exist: Axum extractors are fallible. Rather than panicking on bad input, each extractor returns a typed rejection that implements IntoResponse. This keeps the extraction logic decoupled from error formatting.
5. WithRejection (axum-extra)
WithRejection wraps any extractor to customize its rejection response. Use it to return structured JSON error bodies instead of plain text.
use axum_extra::extract::WithRejection;
use axum::extract:: rejection::JsonRejection;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde_json::json;
// Define a custom rejection type
struct ApiRejection(Response);
impl From<JsonRejection> for ApiRejection {
fn from(rejection: JsonRejection) -> Self {
let (status, message) = match rejection {
JsonRejection::JsonDataError(e) => (StatusCode::BAD_REQUEST, e.body_text()),
JsonRejection::JsonSyntaxError(e) => (StatusCode::BAD_REQUEST, e.body_text()),
JsonRejection::MissingJsonContentType(_) => {
(StatusCode::UNSUPPORTED_MEDIA_TYPE, "expected Content-Type: application/json".into())
}
other => (StatusCode::INTERNAL_SERVER_ERROR, other.to_string()),
};
let body = json!({ "error": message });
ApiRejection((status, axum::Json(body)).into_response())
}
}
impl IntoResponse for ApiRejection {
fn into_response(self) -> Response { self.0 }
}
// Use WithRejection to apply it
async fn create_user(WithRejection(Json(user), _): WithRejection<axum::Json<CreateUser>, ApiRejection>) -> String {
user.name
}Why WithRejection? Without it, customizing rejection responses requires wrapping every extractor in Result and matching on the rejection type inside each handler. WithRejection centralizes this logic once per extractor.
6. BoxError
Box<dyn Error + Send + Sync> (aliased as BoxError in axum) erases concrete error types. This is useful when a handler can fail with many unrelated error types and you don't need to match on them later.
use axum::{response::IntoResponse, http::StatusCode, BoxError};
use std::error::Error;
async fn polyglot_handler() -> Result<String, BoxError> {
let db_result: Result<String, sqlx::Error> = Err(sqlx::Error::RowNotFound);
let db_data = db_result?; // sqlx::Error -> BoxError via ? coercion
let io_result: Result<Vec<u8>, std::io::Error> = Err(std::io::Error::new(
std::io::ErrorKind::NotFound, "file missing"
));
let _bytes = io_result?; // std::io::Error -> BoxError
Ok(db_data)
}Why BoxError is a trade-off: It avoids defining a large enum of error variants but sacrifices type information. Callers cannot match on the inner error. Reserve it for prototypes, fallback handlers, or cases where the error is only logged, never inspected.
7. Error Propagation Patterns
The ? operator
The ? operator calls From::from on the error, so define impl From<LibError> for AppError to let ? perform conversions automatically.
impl From<sqlx::Error> for AppError {
fn from(e: sqlx::Error) -> Self { AppError::Database(e) }
}
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self { AppError::Internal(e.to_string()) }
}
// Now ? just works:
async fn read_config() -> Result<String, AppError> {
let config = tokio::fs::read_to_string("config.toml").await?; // io::Error -> AppError
Ok(config)
}map_err
Use map_err when a one-off conversion doesn't warrant a From impl.
async fn one_off() -> Result<String, AppError> {
let data = some_lib::fetch()
.await
.map_err(|e| AppError::NotFound(format!("upstream: {e}")))?;
Ok(data)
}anyhow integration
use anyhow::Result;
async fn anyhow_handler() -> Result<String> {
let data = tokio::fs::read_to_string("config.toml").await?;
Ok(data)
}
// anyhow::Error -> IntoResponse via a thin wrapper:
struct AppError(anyhow::Error);
impl IntoResponse for AppError {
fn into_response(self) -> Response {
(StatusCode::INTERNAL_SERVER_ERROR, self.0.to_string()).into_response()
}
}
impl From<anyhow::Error> for AppError {
fn from(e: anyhow::Error) -> Self { AppError(e) }
}8. Combining Multiple Error Types with thiserror
thiserror derives Display, Error, and From automatically, reducing boilerplate for error enums.
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("not found: {0}")]
NotFound(String),
#[error("unauthorized")]
Unauthorized,
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
#[error("validation: {0}")]
Validation(String),
#[error("internal error: {0}")]
Internal(#[from] anyhow::Error),
}
// Each #[from] generates an impl From<T> for AppError automatically.
// The #[error("...")] attribute generates Display.
// Status mapping lives in IntoResponse:
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
AppError::Validation(_) => (StatusCode::BAD_REQUEST, self.to_string()),
AppError::Database(_) | AppError::Internal(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}Why thiserror? Manual Display + Error + From impls for enums with many variants are verbose and repetitive. thiserror generates all three from a single declarative attribute, and the #[from] attribute provides automatic From conversions for the ? operator.
9. Fallback Error Handlers
Register a fallback handler on the router to catch all unhandled errors and produce a consistent response.
use axum::{routing::get, Router, response::IntoResponse, http::StatusCode};
use serde_json::json;
async fn fallback() -> impl IntoResponse {
(StatusCode::NOT_FOUND, Json(json!({ "error": "resource not found" })))
}
let app = Router::new()
.route("/users", get(list_users))
.fallback(fallback);For errors from middleware or handlers that return BoxError:
async fn handle_box_error(err: BoxError) -> impl IntoResponse {
if err.is::<tower::timeout::error::Elapsed>() {
return (StatusCode::REQUEST_TIMEOUT, Json(json!({ "error": "request timed out" }))).into_response();
}
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "internal server error" })),
).into_response()
}
// Attach via middleware:
// .layer(HandleErrorLayer::new(handle_box_error))10. axum-extra ErrorResponse
axum_extra::response::ErrorResponse provides a structured error response type that implements IntoResponse.
use axum_extra::response::ErrorResponse;
use axum::http::StatusCode;
use axum::response::IntoResponse;
// ErrorResponse can be returned from IntoResponse directly:
fn bad_request() -> ErrorResponse {
ErrorResponse::from(StatusCode::BAD_REQUEST)
}
// It pairs naturally with custom error types:
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = match &self {
AppError::NotFound(_) => StatusCode::NOT_FOUND,
AppError::Unauthorized => StatusCode::UNAUTHORIZED,
AppError::Validation(_) => StatusCode::BAD_REQUEST,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
ErrorResponse::from(status).into_response()
}
}11. Common Patterns
API error enum with per-variant status codes
#[derive(Error, Debug)]
enum ApiError {
#[error("{0}")]
BadRequest(String),
#[error("unauthorized")]
Unauthorized,
#[error("{0}")]
NotFound(String),
#[error("forbidden")]
Forbidden,
#[error("internal: {0}")]
Internal(#[from] anyhow::Error),
}
impl ApiError {
fn status(&self) -> StatusCode {
match self {
ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
ApiError::NotFound(_) => StatusCode::NOT_FOUND,
ApiError::Forbidden => StatusCode::FORBIDDEN,
ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = self.status();
(status, Json(json!({ "error": self.to_string() }))).into_response()
}
}Database error to AppError conversion
impl From<sqlx::Error> for AppError {
fn from(err: sqlx::Error) -> Self {
match err {
sqlx::Error::RowNotFound => AppError::NotFound("record not found".into()),
sqlx::Error::Database(db_err) if db_err.code().as_deref() == Some("23505") => {
AppError::Conflict("duplicate key".into())
}
other => AppError::Internal(other.to_string()),
}
}
}Validation errors with field-level detail
#[derive(Error, Debug)]
enum ValidationError {
#[error("field '{field}': {message}")]
Field { field: String, message: String },
#[error("{0}")]
Multiple(Vec<ValidationError>),
}
impl IntoResponse for ValidationError {
fn into_response(self) -> Response {
let body = match &self {
ValidationError::Field { field, message } => {
json!({ "errors": [{ "field": field, "message": message }] })
}
ValidationError::Multiple(errors) => {
let items: Vec<_> = errors.iter().map(|e| json!({
"field": match e {
ValidationError::Field { field, .. } => field,
_ => "_general",
},
"message": e.to_string(),
})).collect();
json!({ "errors": items })
}
};
(StatusCode::UNPROCESSABLE_ENTITY, Json(body)).into_response()
}
}Error logging in middleware
use axum::{
body::Body,
http::{Request, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
async fn error_logging_middleware(req: Request<Body>, next: Next) -> Response {
let response = next.run(req).await;
let status = response.status();
if status.is_server_error() {
tracing::error!(status = %status, "server error occurred");
} else if status.is_client_error() {
tracing::warn!(status = %status, "client error occurred");
}
response
}
// Apply: Router::new().layer(middleware::from_fn(error_logging_middleware))12. HTTP Status Code Mapping
| Error Category | Status Code | When to Use |
|---|---|---|
| Bad input syntax | 400 | Malformed JSON, invalid query params |
| Missing auth | 401 | No token / expired token |
| Forbidden | 403 | Valid auth but insufficient permissions |
| Not found | 404 | Resource does not exist |
| Conflict | 409 | Unique constraint violation, duplicate create |
| Validation failure | 422 | Semantically invalid (e.g., email format, field rules) |
| Rate limited | 429 | Too many requests |
| Internal error | 500 | Unexpected failure (never expose internals to clients) |
| Gateway timeout | 504 | Upstream service did not respond in time |
Rule of thumb: Client errors (4xx) mean the request is at fault; server errors (5xx) mean your code or infrastructure is at fault. Never return 500 for bad input, and never return 400 for a missing database record.
Common Pitfalls
1. Forgetting `impl IntoResponse` on the error type. If E does not implement IntoResponse, Result<T, E> won't implement IntoResponse either, and the handler won't compile. The error message can be confusing—look for "the trait IntoResponse is not implemented for Result<…, YourError>".
2. Implementing `Display` but not `Error`. If you #[derive(Error)] via thiserror this is handled automatically. If you implement IntoResponse by hand on an enum that doesn't implement std::error::Error, you lose source() chaining and anyhow compatibility.
3. Returning `Result<_, anyhow::Error>` directly. anyhow::Error does not implement IntoResponse. Wrap it: define AppError(anyhow::Error) with a From impl and IntoResponse on the wrapper.
4. Exposing internal errors to clients. In the IntoResponse impl for AppError::Internal, log the full error with tracing::error! but return a generic message like "internal server error" in the response body. Leaking stack traces or SQL error messages to clients is a security risk.
5. Not implementing `From` for extractor rejections. If a handler uses Path<T> and Json<U> and returns Result<_, AppError>, you need impl From<PathRejection> for AppError and impl From<JsonRejection> for AppError (or use WithRejection) so that ? works inside handlers that use extractors.
6. Shadowing `std::error::Error`. If you define a struct called Error in your crate root and then write impl std::error::Error for Error {}, name conflicts can cause confusing compile errors. Prefer names like AppError or ApiError.
Axum 0.8.x Request Extractors — Reference Guide
Extractors pull data out of an incoming HTTP request and hand it to your handler as typed function parameters. Axum splits them into two families based on whether they read the request body. Understanding this split is the key to using extractors correctly.
---
1. FromRequestParts Extractors (no body consumed)
These implement FromRequestParts, meaning they inspect only the request's URI, headers, and metadata. Multiple parts-extractors can appear as handler parameters in any order because none of them advance the body stream.
Path\<T\> — URL path segments
Path segments are captured by route definitions and deserialized into the handler type.
use axum::{routing::get, Router, extract::Path};
// Single captured segment
async fn single(id: Path<u32>) {
let id: u32 = id.0; // newtype wrapper
}
// Tuple form — matches positional capture groups
async fn tuple(Path((user, repo)): Path<(String, String)>) {
// route: "/{user}/{repo}" → Path<(String, String)>
}
// Named struct — order-independent, self-documenting
#[derive(serde::Deserialize)]
struct RepoPath { user: String, repo: String }
async fn named(Path(RepoPath { user, repo }): Path<RepoPath>) {}
let app = Router::new()
.route("/items/{id}", get(single))
.route("/{user}/{repo}", get(named));Why tuples work: serde can deserialize from a sequence (the captured segments), so Path<(A, B)> works when the route defines two segments.
Query\<T\> — query string deserialization
use axum::{extract::Query, response::IntoResponse};
#[derive(serde::Deserialize)]
struct Pagination { page: Option<usize>, per_page: Option<usize> }
async fn list(Query(pag): Query<Pagination>) -> String {
format!("page={:?}, per_page={:?}", pag.page, pag.per_page)
}
// Parse a query string from any URI without a request (unit test friendly)
fn parse_any_uri() {
let uri: http::Uri = "/search?q=rust&lang=en".parse().unwrap();
let Query(params) = Query::<std::collections::HashMap<String, String>>::try_from_uri(&uri).unwrap();
assert_eq!(params["q"], "rust");
}Why `try_from_uri` exists: It constructs the extractor from a bare URI so you can reuse your deserialization logic in tests and middleware without faking a full request.
State\<T\> — shared application state
use axum::{extract::State, routing::get, Router};
#[derive(Clone)] // Clone is required — every request gets its own copy
struct AppState { db: sqlx::PgPool, version: String }
async fn version(State(state): State<AppState>) -> String {
state.version.clone()
}
let app = Router::new()
.route("/version", get(version))
.with_state(AppState { db: pool, version: "1.0".into() });Why Clone: Axum internally wraps state in Arc. When a handler calls State(state), the extractor clones the Arc (cheap pointer copy). Your type must be Clone so this mechanism compiles. Use Arc inside your state for data you do not want to duplicate.
Extension\<T\> — runtime-injected per-request data
Middleware adds Extension values; handlers read them:
use axum::extract::Extension;
#[derive(Clone)]
struct RequestId(String);
// Middleware that injects the value
async fn add_request_id(
mut req: axum::http::Request<axum::body::Body>,
next: axum::middleware::Next,
) -> axum::response::Response {
let id = uuid::Uuid::new_v4().to_string();
req.extensions_mut().insert(RequestId(id));
next.run(req).await
}
async fn handler(Extension(RequestId(id)): Extension<RequestId>) -> String {
id
}Why Extension vs State: State is set once at router construction and is the same for every request. Extension is inserted per-request by middleware, so different requests can carry different values (auth claims, trace IDs, etc.).
Other parts extractors
use axum::extract::{HeaderMap, MatchedPath, OriginalUri, ConnectInfo};
use axum::http::request::Parts;
async fn misc(
headers: HeaderMap, // all headers as HeaderMap
matched: MatchedPath, // e.g. "/items/{id}"
original: OriginalUri, // URI before any middleware rewrote it
ConnectInfo(addr): ConnectInfo<std::net::SocketAddr>, // client IP
parts: Parts, // raw http::request::Parts
) -> String {
format!("{addr}")
}
// Build the router to populate ConnectInfo (requires `tokio` feature on axum):
// let app = Router::new()
// .route("/", get(misc))
// .into_make_service_with_connect_info::<SocketAddr>();Why `MatchedPath` differs from the actual URI: A request to /items/42 has URI /items/42, but MatchedPath returns the route pattern /items/{id}. This is essential for metrics — you want to group by route, not by every distinct URL.
---
2. FromRequest Extractors (consume the body)
These implement FromRequest. They read and exhaust the request body, so only one can appear per handler and it must be the last parameter. If you put a body extractor before a parts extractor, the code will fail to compile.
Json\<T\> — JSON request body
use axum::{extract::Json, routing::post};
use serde::Deserialize;
#[derive(Deserialize)]
struct CreateUser { name: String, email: String }
async fn create_user(Json(payload): Json<CreateUser>) -> &'static str {
// Json<T> also derefs to T
&payload.name
}
// Manual JSON parsing without a request (useful in tests):
fn parse_json_bytes() {
let bytes = b#"{"name":"Alice"}"#;
let user: CreateUser = serde_json::from_slice(bytes).unwrap();
// Or equivalently: let user = Json::from_bytes(bytes).unwrap().0;
}Why content-type is checked: Json rejects requests whose Content-Type header is not application/json. This prevents a malformed POST with text/plain from silently producing garbage. Use axum-extra's JsonDeserializer for lenient parsing.
Form\<T\> — URL-encoded body
use axum::extract::Form;
#[derive(serde::Deserialize)]
struct Login { username: String, password: String }
async fn login(Form(login): Form<Login>) -> &'static str {
"ok"
}String and bytes::Bytes — raw body
async fn raw_text(body: String) -> String {
format!("got {} bytes", body.len())
}
async fn raw_bytes(body: bytes::Bytes) -> usize {
body.len()
}Why `String` may fail: It requires valid UTF-8. If the body is binary, use Bytes. Bytes never fails and is zero-copy (reference-counted).
Multipart — file uploads (requires multipart feature)
use axum::extract::Multipart;
async fn upload(mut multipart: Multipart) -> &'static str {
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or_default().to_string();
if let Ok(data) = field.bytes().await {
println!("field {name}: {} bytes", data.len());
}
}
"ok"
}
// Cargo.toml: axum = { version = "0.8", features = ["multipart"] }Request and Body — low-level access
use axum::{body::Body, http::Request};
async fn full(req: Request<Body>) {}Use these only when you need complete control (e.g., proxying the body elsewhere).
---
3. axum-extra Extractors
Add axum-extra to your dependencies: axum-extra = "0.10".
TypedHeader\<T\> — single header, type-safe
use axum_extra::{TypedHeader, headers::{UserAgent, Authorization, authorization::Bearer}};
use axum_extra::headers;
async fn agent(TypedHeader(ua): TypedHeader<UserAgent>) -> String {
ua.to_string()
}
async fn bearer(TypedHeader(Auth(bearer)): TypedHeader<Authorization<Bearer>>) -> String {
bearer.token().to_string()
}Why TypedHeader exists: HeaderMap gives you raw string values. TypedHeader<UserAgent> parses the header into a typed struct, rejecting malformed values at the extractor level with a proper 400 response instead of panicking in your handler.
Host
use axum_extra::extract::Host;
async fn host(Host(hostname): Host) -> String {
hostname
}Cookie jars
use axum_extra::extract::{CookieJar, Cookie};
async fn set_cookie(jar: CookieJar) -> (CookieJar, &'static str) {
let jar = jar.add(Cookie::new("session_id", "abc123"));
(jar, "cookie set")
}
async fn read_cookie(jar: CookieJar) -> Option<&'static str> {
jar.get("session_id").map(|_| "found")
}SignedCookieJar uses HMAC to detect tampering; PrivateCookieJar encrypts values. Both require key configuration at the router level.
Either\<E1, E2\> — accept multiple body types
use axum_extra::extract::Either;
use axum::extract::Json;
#[derive(serde::Deserialize)]
struct Payload { data: String }
async fn flexible(
body: Either<Json<Payload>, String>,
) -> String {
match body {
Either::E1(Json(p)) => format!("json: {}", p.data),
Either::E2(s) => format!("text: {s}"),
}
}Why Either: Without it you would need two separate routes for the same logical endpoint. Either lets a single handler accept JSON or plain text (or any two FromRequest types) and branch on which one succeeded.
OptionalQuery\<T\>
use axum_extra::extract::OptionalQuery;
#[derive(serde::Deserialize)]
struct Filter { tag: Option<String> }
async fn items(OptionalQuery(filter): OptionalQuery<Filter>) -> String {
match filter {
Some(f) => format!("filtered by {:?}", f.tag),
None => "no filter".into(),
}
}Why not `Option<Query<T>>`: In axum 0.8, Option<Query<T>> requires the query string to be _present_ but deserialization may fail. OptionalQuery<T> returns None when the entire query string is absent, which is usually what you want.
WithRejection\<T, R\> — custom rejection types
use axum::{extract::FromRequest, http::StatusCode, response::{IntoResponse, Response}};
// Replace the default 400 JSON rejection with a plain-text 422
#[derive(Clone)]
struct MyRejection;
impl IntoResponse for MyRejection {
fn into_response(self) -> Response {
(StatusCode::UNPROCESSABLE_ENTITY, "invalid payload").into_response()
}
}
async fn handler(
body: axum_extra::extract::WithRejection<Json<Payload>, MyRejection>,
) -> &'static str {
"ok"
}---
4. Custom Extractors
Implement FromRequestParts for metadata extractors or FromRequest for body extractors. Axum 0.8 uses native impl Future — no #[async_trait] needed.
Custom parts extractor (auth token)
use axum::{
extract::{FromRequestParts, Request},
http::{request::Parts, StatusCode, header},
response::{IntoResponse, Response},
};
use futures::future::BoxFuture;
#[derive(Clone)]
struct AuthToken(String);
struct AuthRejection(StatusCode);
impl IntoResponse for AuthRejection {
fn into_response(self) -> Response {
(self.0, "missing or invalid auth token").into_response()
}
}
// Native async impl — no #[async_trait] in axum 0.8
impl<S> FromRequestParts<S> for AuthToken
where
S: Send + Sync,
{
type Rejection = AuthRejection;
fn from_request_parts(parts: &mut Parts, _state: &S) -> BoxFuture<'_, Result<Self, Self::Rejection>> {
Box::pin(async move {
let header = parts
.headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or(AuthRejection(StatusCode::UNAUTHORIZED))?;
let token = header
.strip_prefix("Bearer ")
.ok_or(AuthRejection(StatusCode::UNAUTHORIZED))?;
Ok(AuthToken(token.to_string()))
})
}
}
async fn protected(AuthToken(token): AuthToken) -> String {
token
}Custom body extractor
Implement FromRequest the same way, but receive Request<Body> instead of &mut Parts. Call request.into_body() to obtain the body stream.
---
5. Option\<T\> and Result\<T, E\> Patterns
In axum 0.8, Option<T> wrapping an extractor uses the OptionalFromRequestParts / OptionalFromRequest traits internally:
- `Option<Query<T>>` — succeeds even if the query string is present but empty or
malformed; returns None on _any_ failure. This means a typo like ?pag=1 (instead of ?page=1) silently gives None rather than a 400 error.
- `Option<TypedHeader<UserAgent>>` — returns
Noneif the header is missing or
malformed.
- `Result<Query<T>, QueryRejection>` — returns the rejection so you can produce a
custom error response.
async fn flexible(
// Will not reject if query is absent
opt: Option<Query<Pagination>>,
) -> String {
match opt {
Some(Query(p)) => format!("page {:?}", p.page),
None => "no params".into(),
}
}Key change from 0.7: In 0.7, Option<T> used a different internal mechanism that could silently swallow parse errors. In 0.8, OptionalFromRequestParts is the dedicated trait, and its behavior is more predictable — it always returns None on failure.
⚠️ Silent Failure Trap with TypedHeader and Malformed Data
A common mistake is assuming Option<TypedHeader<T>> returns None for all failure cases:
// DANGEROUS: Will reject with 400 if header is present but malformed
async fn handler(altcha: Option<TypedHeader<XAltchaSolution>>) -> Result<Json<()>, AppError> {
// If client sends a well-formed header → extracted
// If header is absent → None (safe)
// If header is PRESENT but malformed (e.g., bad Base64) → REQUEST REJECTED with 400!
}In axum 0.8, if a header is present but fails to parse (Base64 decode error, invalid format, etc.), the extractor rejects the request rather than returning None. This breaks the "optional" assumption.
The fix: Extract HeaderMap and parse manually
use axum::{
extract::HeaderMap,
response::IntoResponse,
};
use base64::Engine;
#[derive(Debug)]
enum AppError {
AltchaParseFailed(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::AltchaParseFailed(msg) => {
(StatusCode::BAD_REQUEST, format!(r#"{{"error":"altcha_parse_failed","details":"{}"}}"#, msg))
}
};
(status, message).into_response()
}
}
fn parse_altcha_header(headers: &HeaderMap) -> Result<AltchaPayload, AppError> {
let header = headers
.get("X-Altcha")
.ok_or_else(|| AppError::AltchaParseFailed("header missing".into()))?;
let header_str = header
.to_str()
.map_err(|_| AppError::AltchaParseFailed("invalid header encoding".into()))?;
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(header_str)
.map_err(|e| AppError::AltchaParseFailed(format!("base64 decode error: {}", e)))?;
serde_json::from_slice(&decoded)
.map_err(|e| AppError::AltchaParseFailed(format!("json parse error: {}", e)))
}
async fn handler(headers: HeaderMap) -> Result<Json<()>, AppError> {
let altcha = parse_altcha_header(&headers)?;
// use altcha...
Ok(Json(()))
}Key differences:
Option<TypedHeader<T>>→ returnsNoneonly if header is absentHeaderMap+ manual parse → full control over error handling, returns your custom JSON error
---
6. Extractor Ordering Rules
Axum enforces ordering at compile time through Rust's type system. The framework generates a tuple implementation of FromRequestParts / FromRequest for your handler parameters. The rules:
1. All `FromRequestParts` extractors can appear in any order and in any quantity. 2. At most one `FromRequest` extractor is allowed per handler. 3. The `FromRequest` extractor must be the last parameter (or the only parameter).
// ✅ Compiles — parts, parts, body (last)
async fn ok(
Query(params): Query<Params>,
State(state): State<AppState>,
Json(body): Json<Payload>,
) -> Response { todo!() }
// ❌ Compile error — body extractor not last
// async fn bad(
// Json(body): Json<Payload>,
// Query(params): Query<Params>, // compiler rejects this
// ) {}Why this is a compile-time error: Axum's handler macro generates an extractor chain. The tuple (Query<T>, Json<U>) tries to impl FromRequest, which calls FromRequestParts for each element and FromRequest for the last. If the FromRequest element is not last, the generated code cannot type-check because body has already been moved.
---
Common Pitfalls
1. Forgetting `Clone` on State — The state type must implement Clone. Wrap expensive fields in Arc to avoid deep copies.
2. `Option<Query<T>>` silently swallowing errors — A malformed query string (e.g. ?page=abc for a usize field) returns None instead of a 400. Use Query<T> directly if you want validation errors, or OptionalQuery<T> from axum-extra if you only want to treat _absence_ as optional.
3. Multiple body extractors — You cannot have both Json<T> and Form<U> in the same handler. The body stream can only be read once. Use Either from axum-extra if you need to accept different content types on the same route.
4. Using `String` body for binary data — String requires valid UTF-8 and will reject binary payloads with a 400. Use bytes::Bytes instead.
5. Missing `multipart` feature — The Multipart extractor requires the multipart cargo feature on axum. Without it, the type does not exist.
6. ConnectInfo not populated — ConnectInfo<SocketAddr> only works when the server is created with into_make_service_with_connect_info::<SocketAddr>(), not with the default into_make_service().
7. Extension type collisions — Inserting two values of the same type into Extension silently overwrites the first. Use a newtype wrapper (e.g., struct AuthUser(User)) to avoid collisions.
Axum 0.8 File Handling & Uploads Reference
Multipart Form Handling
Axum uses the axum::extract::Multipart extractor to parse multipart/form-data requests. It consumes the request body stream, so the extractor takes ownership — you cannot combine it with another body extractor on the same handler.
Basic Multipart Extraction
use axum::{extract::Multipart, response::IntoResponse, http::StatusCode};
use axum_extra::extract::MultipartError;
async fn upload(mut multipart: Multipart) -> Result<impl IntoResponse, (StatusCode, String)> {
while let Some(field) = multipart.next_field().await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
{
// Each field has these properties:
let _name: Option<String> = field.file_name(); // Original filename from Content-Disposition
let _content_type: Option<String> = field.content_type(); // MIME type from Content-Type header
let _headers: &axum::http::HeaderMap = field.headers(); // Raw headers on this part
// Read entire field into memory as raw bytes:
let data = field.bytes().await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
// Read as UTF-8 text (returns error if bytes are not valid UTF-8):
let text = field.text().await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
// Stream chunks manually (see Streaming section below):
// while let Some(chunk) = field.chunk().await.map_err(...)? { ... }
}
Ok(StatusCode::OK)
}Why it works: The Multipart extractor reads from the request body using hyper's streaming API. Each call to next_field() parses the next MIME boundary segment. bytes() consumes all remaining chunks into a contiguous Bytes buffer; text() does the same but additionally validates UTF-8. chunk() yields one chunk at a time without buffering the entire field, which is critical for large uploads.
File Upload Patterns
Single File Upload
use axum::{extract::Multipart, Json};
use serde_json::{json, Value};
async fn upload_single(mut multipart: Multipart) -> Result<Json<Value>, (StatusCode, String)> {
let field = multipart
.next_field().await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
.ok_or((StatusCode::BAD_REQUEST, "no field provided".into()))?;
let filename = field.file_name()
.ok_or((StatusCode::BAD_REQUEST, "missing filename".into()))?
.to_string();
let content_type = field.content_type()
.ok_or((StatusCode::BAD_REQUEST, "missing content-type".into()))?
.to_string();
let data = field.bytes().await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
// Save to disk (see filesystem section below for production patterns)
tokio::fs::write(format!("/tmp/uploads/{filename}"), &data).await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(json!({ "filename": filename, "size": data.len(), "content_type": content_type })))
}Why it works: file_name() reads the filename parameter from the Content-Disposition header of the multipart field. It returns None for non-file fields, so we validate its presence to enforce a file upload.
Multiple File Upload
use axum::{extract::Multipart, Json};
use serde_json::{json, Value};
async fn upload_many(mut multipart: Multipart) -> Result<Json<Value>, (StatusCode, String)> {
let mut saved = Vec::new();
while let Some(field) = multipart.next_field().await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
{
let Some(filename) = field.file_name() else { continue };
let filename = filename.to_string();
let data = field.bytes().await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
tokio::fs::write(format!("/tmp/uploads/{filename}"), &data).await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
saved.push(json!({ "filename": filename, "size": data.len() }));
}
Ok(Json(json!({ "files": saved })))
}Mixed Form Fields (Text + Files)
use axum::{extract::Multipart, Json};
use serde_json::{json, Value};
async fn upload_mixed(mut multipart: Multipart) -> Result<Json<Value>, (StatusCode, String)> {
let mut description = String::new();
let mut files = Vec::new();
while let Some(field) = multipart.next_field().await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
{
let name = field.name().unwrap_or_default().to_string();
if name == "description" {
description = field.text().await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
} else if let Some(filename) = field.file_name() {
let data = field.bytes().await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
files.push(json!({ "filename": filename, "size": data.len() }));
}
}
Ok(Json(json!({ "description": description, "files": files })))
}Why it works: field.name() returns the form field name (the part before = in Content-Disposition: form-data; name="..."). File fields also have a filename attribute; text-only fields do not. Use file_name() being None vs Some to distinguish them.
Streaming Large File Uploads with chunk()
use axum::{extract::Multipart, http::StatusCode};
use tokio::io::AsyncWriteExt;
async fn upload_streaming(mut multipart: Multipart) -> Result<StatusCode, (StatusCode, String)> {
while let Some(field) = multipart.next_field().await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
{
let Some(filename) = field.file_name() else { continue };
let path = format!("/tmp/uploads/{filename}");
let mut file = tokio::fs::File::create(&path).await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Read one chunk at a time — avoids buffering the entire file in memory
while let Some(chunk) = field.chunk().await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
{
file.write_all(&chunk).await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}
}
Ok(StatusCode::OK)
}Why it works: chunk() returns Some(Bytes) for each data segment between MIME boundaries, and None when the field is complete. This lets you write each chunk to disk immediately, keeping peak memory usage proportional to chunk size rather than total file size. The default chunk size comes from hyper's framing (typically 8 KiB–64 KiB).
Saving to Filesystem with tokio::fs
use std::path::Path;
use axum::extract::Multipart;
async fn save_uploads(
mut multipart: Multipart,
base_dir: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
tokio::fs::create_dir_all(base_dir).await?;
while let Some(field) = multipart.next_field().await? {
let Some(filename) = field.file_name() else { continue };
// Sanitize: strip directory components to prevent path traversal
let safe_name = std::path::Path::new(filename)
.file_name()
.ok_or("invalid filename")?;
let dest = base_dir.join(safe_name);
let data = field.bytes().await?;
tokio::fs::write(&dest, &data).await?;
}
Ok(())
}Why `tokio::fs`: Standard std::fs operations block the async executor thread. tokio::fs spawns blocking I/O onto Tokio's blocking thread pool, preventing the executor from stalling.
Upload Size Limits
Axum's default body limit is ~2 MB. Exceeding it returns a 413 Payload Too Large response before your handler runs.
Global Limit
use axum::{routing::post, Router, body::Body, extract::DefaultBodyLimit};
let app = Router::new()
.route("/upload", post(upload_handler))
// Raise to 50 MB for the entire application
.layer(DefaultBodyLimit::max(50 * 1024 * 1024));Why it works: DefaultBodyLimit is implemented as a tower layer that wraps the body stream with a size-checking wrapper. When the accumulated bytes exceed the limit, it immediately returns a 413 response and drops the connection.
Per-Route Limit
use axum::{routing::{get, post}, Router};
use axum::extract::DefaultBodyLimit;
let app = Router::new()
.route("/upload", post(upload_handler))
// Override limit for this single route (10 MB)
.route("/upload", post(upload_handler)
.layer(DefaultBodyLimit::max(10 * 1024 * 1024)))
.route("/small-upload", post(small_handler))
.route("/api/data", get(data_handler));
// /api/data retains the default ~2 MB limitDisable Limit (use with caution)
.layer(DefaultBodyLimit::disable())
// WARNING: an attacker can exhaust server memory by sending an unbounded bodyMultipart Error Handling
The Multipart extractor uses axum::extract::rejection::MultipartRejection as its rejection type. Common inner errors include MultipartError::InvalidBoundary (malformed request) and MultipartError::IncompleteField (client disconnected mid-upload).
use axum::{
extract::Multipart,
http::StatusCode,
response::{IntoResponse, Response},
};
enum AppError {
Multipart(axum::extract::rejection::MultipartRejection),
Io(std::io::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::Multipart(r) => {
// MultipartRejection contains the underlying MultipartError
(StatusCode::BAD_REQUEST, format!("multipart error: {r}"))
}
AppError::Io(e) => {
(StatusCode::INTERNAL_SERVER_ERROR, format!("io error: {e}"))
}
};
(status, message).into_response()
}
}
// Return Result<_, AppError> to get automatic rejection conversion
async fn safe_upload(multipart: Result<Multipart, AppError>) -> Response {
match multipart {
Ok(mut mp) => {
while let Ok(Some(field)) = mp.next_field().await {
// process field...
}
StatusCode::OK.into_response()
}
Err(e) => e.into_response(),
}
}Why this pattern: By accepting Result<Multipart, Rejection> in the handler signature, the extractor never rejects early — your handler always runs and can return a structured error response instead of relying on Axum's default plain-text rejection.
Static File Serving
ServeDir for Directories
use axum::{routing::get_service, Router};
use tower_http::services::{ServeDir, ServeFile};
let app = Router::new()
.nest_service("/static", ServeDir::new("assets"));
// GET /static/style.css → serves ./assets/style.css
// GET /static/js/app.js → serves ./assets/js/app.jsWhy `nest_service` vs `route_service`: nest_service strips the matched prefix (/static) before passing the remaining path to the service. route_service does not strip, so ServeDir would need its base path set to include the prefix. For directory serving, always use nest_service.
ServeFile for Individual Files
use axum::routing::get_service;
use tower_http::services::ServeFile;
let app = Router::new()
.route_service("/favicon.ico", ServeFile::new("assets/favicon.ico"));SPA Fallback Pattern
Serve static assets from a directory, but fall back to index.html for unknown paths (required by client-side routers like React Router or Vue Router):
use tower_http::services::{ServeDir, ServeFile};
let app = Router::new().nest_service(
"/",
ServeDir::new("dist")
.fallback(ServeFile::new("dist/index.html")),
);
// GET /about → dist/about (if file exists), else dist/index.html
// GET /static.js → dist/static.jsWhy it works: ServeDir tries to find the file in dist/. If the file does not exist, it delegates to the fallback ServeFile, which returns dist/index.html with a 200 status. This lets the client-side router handle URL paths.
Pre-compressed Files
If you have pre-compressed .gz files alongside your assets, enable automatic negotiation:
ServeDir::new("assets").precompressed_gzip();
// When a client sends Accept-Encoding: gzip and assets/style.css.gz exists,
// ServeDir serves the .gz file with Content-Encoding: gzip automatically.File Download
Setting Content-Disposition Headers
use axum::{
http::{header, StatusCode},
response::{IntoResponse, Response},
};
async fn download_file() -> impl IntoResponse {
let data = tokio::fs::read("files/report.pdf").await.unwrap();
let headers = [
(header::CONTENT_TYPE, "application/pdf".to_string()),
// "attachment" prompts a download; "inline" previews in-browser
(
header::CONTENT_DISPOSITION,
r#"attachment; filename="report.pdf""#.to_string(),
),
];
(StatusCode::OK, headers, data)
}Streaming File Responses with AsyncReadBody (axum-extra)
use axum::response::IntoResponse;
use axum_extra::body::AsyncReadBody;
use tokio::fs::File;
async fn stream_download() -> impl IntoResponse {
let file = File::open("large-video.mp4").await.unwrap();
let body = AsyncReadBody::new(file);
// AsyncReadBody streams from the file without loading it entirely into memory
([(axum::http::header::CONTENT_TYPE, "video/mp4")], body)
}Why `AsyncReadBody`: It wraps any tokio::io::AsyncRead into an axum-compatible body. The file is read on-demand as chunks are sent to the client, so memory usage stays constant regardless of file size. This is essential for serving large media files.
FileStream from axum-extra
use axum_extra::response::FileStream;
async fn file_stream_download() -> impl IntoResponse {
FileStream::new(tokio::fs::File::open("data.csv").await.unwrap())
}Why FileStream over raw AsyncReadBody: FileStream automatically sets Content-Type by inspecting the file extension via the mime_guess crate, and sets Content-Length from file metadata. Use it when you want sensible defaults without manual header configuration.
Attachment Header Helper (axum-extra)
use axum_extra::response::Attachment;
async fn download_with_attachment() -> impl IntoResponse {
let file = tokio::fs::File::open("report.pdf").await.unwrap();
// Sets Content-Disposition: attachment; filename="report.pdf"
// and auto-detects Content-Type
Attachment::new(file).filename("report.pdf")
}Content-Type Handling
tower_http::services::ServeDir and ServeFile automatically detect MIME types from file extensions using the mime_guess crate. This lookup happens at serve time and requires no configuration.
To override the detected content type:
use axum::routing::get_service;
use tower_http::services::ServeFile;
use http::header::CONTENT_TYPE;
// Override: serve a .bin file as application/octet-stream
let service = ServeFile::new("data/export.bin")
.with_headers([
(CONTENT_TYPE, "application/octet-stream".parse().unwrap()),
]);Why you might override: Some CDNs or proxies incorrectly sniff content types. Explicitly setting Content-Type prevents them from misinterpreting binary data as text or HTML (which can cause XSS in reflected-file attacks).
Security Considerations
Path Traversal Prevention
Never use user-supplied filenames directly in filesystem paths:
use std::path::Path;
fn safe_filename(raw: &str) -> Option<&str> {
// Extract only the final component — strips "../" and "/etc/" etc.
Path::new(raw).file_name()?.to_str()
}
// DANGEROUS:
// let path = format!("/uploads/{}", user_filename); // "../secrets" escapes!
// SAFE:
// let safe = safe_filename(user_filename).ok_or("bad name")?;
// let path = base_dir.join(safe);Why `file_name()` works: It returns only the final path component. Path::new("../../etc/passwd").file_name() yields Some("passwd"), and joining that onto your base directory produces /uploads/passwd — safely contained.
File Size Limits
Always set DefaultBodyLimit::max() appropriate to your use case. Unbounded uploads let attackers exhaust server memory:
// Images: 10 MB
// Documents: 50 MB
// Videos: 500 MB (and use streaming!)
DefaultBodyLimit::max(10 * 1024 * 1024)Content-Type Validation
Validate that uploaded files match their declared content type:
fn is_allowed_content_type(ct: Option<&str>) -> bool {
ct.is_some_and(|t| {
matches!(t,
"image/png" | "image/jpeg" | "image/gif" | "image/webp"
| "application/pdf"
)
})
}
// In your handler:
let ct = field.content_type().unwrap_or_default();
if !is_allowed_content_type(Some(ct)) {
return Err((StatusCode::UNSUPPORTED_MEDIA_TYPE, "file type not allowed".into()));
}Why it matters: Browsers determine file behavior by Content-Type. An uploaded SVG file served as text/html enables stored XSS. An uploaded HTML file served inline executes scripts. Always validate server-side — never trust client-supplied content-type headers alone.
Common Pitfalls
1. Forgetting `DefaultBodyLimit` — uploads silently fail with 413. Always configure an explicit limit and surface the error in tests.
2. Using `std::fs` in async handlers — blocks the executor thread. Always use tokio::fs or tokio::task::spawn_blocking.
3. Calling `bytes()` after `chunk()` (or vice versa) — these methods consume the field's internal stream. Call only one per field.
4. Double-extracting `Multipart` and `String` body — the body stream can only be consumed once. Combine all parsing into the multipart handler.
5. Missing `nest_service` for `ServeDir` — using route_service("/static", ServeDir::new(".")) serves files at paths like /static/path but ServeDir looks for ./static/path. Use nest_service to strip the prefix.
6. Not sanitizing filenames — user-supplied filenames like ../../../etc/passwd cause path traversal. Always extract file_name() and join onto a fixed base directory.
7. Serving user uploads with `ServeDir` — if users can upload .html or .svg files and those are served from the same ServeDir, you have a stored XSS vulnerability. Serve user uploads from a separate path or with forced Content-Disposition: attachment.
8. Ignoring `MultipartRejection` — the default rejection returns a plain 400 with no detail. Accept Result<Multipart, MultipartRejection> in your handler to return structured error JSON.
Migrating from axum 0.7 to 0.8
Complete reference for upgrading every breaking change. Each section explains why the change happened and provides before/after examples.
---
1. Path Parameter Syntax
The colon-based syntax /:id conflicted with URI templates in other specs and made it impossible to distinguish parameters from literal segments. Axum 0.8 adopts the matchit 0.8 convention using braces.
Before (0.7):
let app = Router::new()
.route("/users/:id", get(get_user))
.route("/files/*path", get(serve_file))
.route("/posts/:id:\\d+", get(get_post_by_number));
async fn get_user(Path(id): Path<u32>) { /* ... */ }
async fn serve_file(Path(path): Path<String>) { /* ... */ }After (0.8):
let app = Router::new()
.route("/users/{id}", get(get_user))
.route("/files/{*path}", get(serve_file))
// Regex constraints use a leading colon before the param name
.route("/posts/{:id}", get(get_post_by_number));Search-and-replace /:(\w+) with {$1} and /\*(\w+) with {*$1}. Adjust regex constraints from {param:pattern} to {:param}.
---
2. Host Extractor Moved to axum-extra
Host extraction depends on http header types that live outside axum's minimal dependency footprint. Moving it to axum-extra keeps the core crate lean.
Before (0.7):
use axum::extract::Host;
async fn handler(Host(host): Host) -> String {
format!("host: {host}")
}After (0.8):
use axum_extra::extract::Host;
async fn handler(Host(host): Host) -> String {
format!("host: {host}")
}Add axum-extra to Cargo.toml with the host feature:
axum-extra = { version = "0.12", features = ["host"] }---
3. WebSocket Message Types Changed
Allocating a new String or Vec<u8> on every message created unnecessary heap pressure. The new types use reference-counted bytes that can be cloned cheaply and shared across tasks.
Before (0.7):
use axum::extract::ws::Message;
while let Some(Ok(msg)) = socket.recv().await {
match msg {
Message::Text(text) => println!("{text}"), // String
Message::Binary(data) => println!("{data:?}"), // Vec<u8>
_ => {}
}
}After (0.8):
use axum::extract::ws::Message;
while let Some(Ok(msg)) = socket.recv().await {
match msg {
Message::Text(text) => println!("{text}"), // Utf8Bytes
Message::Binary(data) => println!("{data:?}"), // Bytes
_ => {}
}
}Convert with .to_string() / .to_vec() or construct directly:
let msg = Message::Text("hello".into()); // &str -> Utf8Bytes
let msg = Message::Binary(vec![1, 2, 3].into()); // Vec<u8> -> Bytes---
4. WebSocket::close() Removed
The implicit close() method made it impossible to send custom close codes or reasons. Sending close frames is now explicit via Message::Close.
Before (0.7):
socket.close().await?;After (0.8):
// Normal close
socket.send(Message::Close(None)).await?;
// Close with code and reason
socket.send(Message::Close(Some(axum::extract::ws::CloseFrame {
code: axum::extract::ws::close_code::NORMAL,
reason: "bye".into(),
}))).await?;Dropping the sender also sends a close frame automatically, so simply letting socket go out of scope is often sufficient.
---
5. Option<T> Extractor Behavior
In 0.7, Option<Path<T>> silently returned None on parse failure, hiding bugs. Now extractors must implement OptionalFromRequestParts or OptionalFromRequest. Only extractors designed for absence (headers, query params) return None; others reject the request.
Before (0.7) — silently swallowed parse errors:
async fn handler(id: Option<Path<u32>>) {
// If the segment was "abc", id would be None — no error logged
}After (0.8) — rejects on parse failure:
// Option<Path<T>> now rejects if the parameter exists but fails to parse.
// Use a String parameter and parse manually if you need soft failure:
async fn handler(id: Option<Path<String>>) {
if let Some(Path(id)) = id {
if let Ok(num) = id.parse::<u32>() {
// use num
}
}
}New optional extractors (Json, Extension, Query, TypedHeader) correctly return None when the item is genuinely absent.
---
6. Sync Requirement — Handlers Must Be Send + Sync
Axum now requires all handler futures to be Send. This is enforced by tower's Service trait. Remove Rc, RefCell, and other non-thread-safe types from handlers and state.
Before (0.7) — could compile with Rc in some setups:
use std::rc::Rc;
use std::cell::RefCell;
async fn handler(State(state): State<Rc<RefCell<AppState>>>) {
state.borrow_mut().count += 1;
}After (0.8) — must use thread-safe types:
use std::sync::Arc;
use std::sync::Mutex;
async fn handler(State(state): State<Arc<Mutex<AppState>>>) {
state.lock().unwrap().count += 1;
}
// Better: use tokio::sync::Mutex for async-friendly locking
use tokio::sync::Mutex;
async fn handler(State(state): State<Arc<Mutex<AppState>>>) {
let mut app = state.lock().await;
app.count += 1;
}Run cargo check and fix any "cannot be sent between threads safely" errors.
---
7. serve() API Changes
The serve function is now generic over the listener and IO types, enabling hyper 1.x flexibility. tcp_nodelay is no longer a method on Serve because the listener itself controls that setting.
Before (0.7):
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app)
.tcp_nodelay(true)
.await?;After (0.8):
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
// Set nodelay on the listener socket itself before passing to serve
let listener = tokio::net::TcpListener::from_std(
listener.into_std()?
).await?;
// Or more simply — just call serve directly:
axum::serve(listener, app).await?;The returned Serve type now implements tower::Service<Incoming> for composability with middleware layers.
---
8. Removed APIs
| Removed Item | Replacement |
|---|---|
axum::extract::Host | axum_extra::extract::Host |
WebSocket::close() | socket.send(Message::Close(...)).await |
axum::extract::ws::Message::Text(String) | Message::Text(Utf8Bytes) |
axum::extract::ws::Message::Binary(Vec<u8>) | Message::Binary(Bytes) |
axum::body::Body::from_stream | Use axum::body::Body::from_stream (still available but now wraps http-body 1.x) |
Serve::tcp_nodelay | Configure on the listener before calling serve |
Route::new() (legacy) | Use Router::new() consistently |
axum::extract::ConnectInfo as-is | Migrate to axum::extract::ConnectInfo<SocketAddr> with explicit listener binding |
Review compiler errors after upgrading; each removal surfaces as a clear error message.
---
9. New APIs
Method Not Allowed Fallback
let app = Router::new()
.route("/users", get(list_users).post(create_user))
.method_not_allowed_fallback(method_not_allowed_handler);NoContent Response
use axum::response::NoContent;
async fn delete_user() -> NoContent {
NoContent
}WebSocket over HTTP/2
WebSocket connections now work over HTTP/2 streams when the client and server both support it. No code change required — it is automatic.
CONNECT Method
use axum::http::Method;
let app = Router::new()
.route("/tunnel", any(tunnel_handler))
.allow_method(Method::CONNECT);WebSocket Upgrade Protocol Selection
use axum::extract::ws::{WebSocketUpgrade, WebSocket};
use futures_util::SinkExt;
async fn ws_handler(ws: WebSocketUpgrade) -> impl IntoResponse {
ws.protocols(["graphql-ws", "ws"])
.on_upgrade(handle_socket)
}
async fn handle_socket(mut socket: WebSocket) {
// socket.protocol() returns the negotiated protocol
}Router Fallback Reset
let app = Router::new()
.route("/api/*path", any(api_handler))
.reset_fallback(); // Clears any previously set fallbackSSE Binary Data
Server-sent events now support binary event data via Event::data(Bytes) in addition to Event::data(String).
---
10. impl OptionalFromRequest for Json and Extension
Standard library extractors now support optional extraction, returning None instead of rejecting when the payload is absent.
Before (0.7) — needed a custom wrapper or middleware:
// Workaround: use Result<Json<T>, _> or a custom extractorAfter (0.8) — use Option directly:
use axum::extract::Json;
use serde::Deserialize;
#[derive(Deserialize)]
struct PatchPayload { name: Option<String> }
async fn patch(body: Option<Json<PatchPayload>>) -> impl IntoResponse {
if let Some(Json(payload)) = body {
// apply partial update
}
// body was empty — no-op
}
// Extension also works optionally:
async fn handler(ext: Option<Extension<MyType>>) -> impl IntoResponse {
if let Some(Extension(val)) = ext {
// use val
}
}---
11. #[async_trait] No Longer Required
Custom extractors and middleware implement tower traits with native async fn in traits thanks to RPITIT (Return Position Impl Trait In Traits). Remove async-trait from your extractor definitions.
Before (0.7):
use async_trait::async_trait;
use axum::extract::FromRequestParts;
#[async_trait]
impl<S> FromRequestParts<S> for MyExtractor
where S: Send + Sync,
{
type Rejection = StatusCode;
async fn from_request_parts(
parts: &mut Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
// ...
}
}After (0.8):
use axum::extract::FromRequestParts;
// No #[async_trait] needed
impl<S: Send + Sync> FromRequestParts<S> for MyExtractor {
type Rejection = StatusCode;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> Result<Self, Self::Rejection> {
// ...
}
}Remove async-trait from Cargo.toml if no other code depends on it.
---
12. Cargo.toml Changes
Update dependency versions and enable new feature flags.
Before (0.7):
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "trace"] }After (0.8):
[dependencies]
axum = { version = "0.8", features = ["ws"] }
axum-extra = { version = "0.12", features = ["typed-header", "host", "cookie"] }
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace"] }Key notes:
towerupgraded to 0.5 (check for breaking changes in custom middleware).tower-httpupgraded to 0.6 (reviewCorsLayer,TraceLayerAPI changes).- The
wsfeature is still required for WebSocket support. - Add
axum-extraif you useHost,Cookie,Querywith serde, or other extended extractors.
---
Migration Checklist
- [ ] Update
Cargo.tomldependency versions (axum 0.8, tower 0.5, tower-http 0.6) - [ ] Add
axum-extrawith needed features (host,typed-header, etc.) - [ ] Run
cargo checkand resolve compiler errors - [ ] Replace
/:parampath syntax with{param}in all route definitions - [ ] Replace
/*pathcatch-all syntax with{*path} - [ ] Adjust regex path constraints from
{param:\\d+}to{:param} - [ ] Move
use axum::extract::Hosttouse axum_extra::extract::Host - [ ] Update
Message::Texthandling fromStringtoUtf8Bytes - [ ] Update
Message::Binaryhandling fromVec<u8>toBytes - [ ] Replace all
socket.close()calls with explicitMessage::Closesends - [ ] Audit
Option<T>extractors: ensure parse failures are handled correctly - [ ] Remove
Rc/RefCellfrom handlers and state; useArc/Mutexinstead - [ ] Remove
.tcp_nodelay()calls fromserve()chains - [ ] Remove
#[async_trait]from custom extractors and middleware - [ ] Remove
async-traitdependency if unused elsewhere - [ ] Verify tower 0.5 / tower-http 0.6 compatibility for custom layers
- [ ] Run full test suite and fix any remaining failures
- [ ] Review the axum 0.8 changelog for edge cases