Axum is the HTTP framework the Rust ecosystem deserved. Built on top of tower and hyper, it composes like LEGO and benchmarks like C. But most tutorials stop at "here’s a Hello World handler" and leave you to figure out the parts that matter in production — request validation, authentication layers, structured errors, and middleware ordering that won’t silently break your auth.
Official repo: https://github.com/tokio-rs/axum
This article targets Axum 0.8, which shipped with a cleaner FromRequestParts story, tightened the State extraction model, and made the #[debug_handler] macro actually useful for diagnosing the infamously cryptic type errors. If you’re migrating from 0.6 or 0.7, there are a few breaking changes I’ll flag along the way.
The Mental Model You Need First
Axum has three moving parts: handlers, extractors, and layers.
A handler is just an async function. Extractors are the arguments to that function — they describe what you pull from the request. Layers wrap your router and process requests before (or after) they hit a handler.
The key insight is that this is all tower under the hood. An axum::Router is a tower::Service. Layers are tower middleware. This means anything in the tower ecosystem — rate limiting, tracing, timeouts — plugs in directly. It also means you need to understand service composition to debug ordering issues, which we’ll cover.
Extractors: Beyond Json<T> and Path<T>
Most guides show you Json, Path, and Query. That covers maybe 40% of what you’ll need.
The Two Extractor Traits
Axum has two traits for extractors:
FromRequestParts— can only read the request parts (headers, URI, method, extensions). Does not consume the body. Multiple of these can run on the same request.FromRequest— gets the whole request including the body. Only one of these can run per handler, and it must be the last argument.
This distinction is load-bearing. Screw it up and you get a compile error that looks like type system nonsense but is actually a clear rule violation.
Custom FromRequestParts: Auth Token Extraction
Here’s the pattern I use for bearer token extraction. It belongs in FromRequestParts because you’re only reading a header.
use axum::{
extract::FromRequestParts,
http::{request::Parts, StatusCode},
response::{IntoResponse, Response},
};
pub struct BearerToken(pub String);
#[derive(Debug)]
pub enum AuthError {
MissingToken,
InvalidFormat,
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
let (status, message) = match self {
AuthError::MissingToken => (StatusCode::UNAUTHORIZED, "Missing authorization header"),
AuthError::InvalidFormat => (StatusCode::UNAUTHORIZED, "Malformed bearer token"),
};
(status, message).into_response()
}
}
impl<S> FromRequestParts<S> for BearerToken
where
S: Send + Sync,
{
type Rejection = AuthError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let auth = parts
.headers
.get("Authorization")
.ok_or(AuthError::MissingToken)?
.to_str()
.map_err(|_| AuthError::InvalidFormat)?;
let token = auth
.strip_prefix("Bearer ")
.ok_or(AuthError::InvalidFormat)?;
Ok(BearerToken(token.to_owned()))
}
}
Now you use it in any handler:
async fn protected_route(
BearerToken(token): BearerToken,
State(db): State<DbPool>,
) -> impl IntoResponse {
// token is already validated to exist and be well-formed
// ...
}
The rejection type is what gets returned when extraction fails. It must implement IntoResponse. Keep your rejection types specific — a single AppError enum that covers everything is fine, but don’t use (StatusCode, String) inline; you’ll regret the inconsistency later.
Custom FromRequest: Validated JSON Body
The standard Json<T> extractor deserializes but doesn’t validate. In production you want serde deserialization plus business rule validation. validator crate is the go-to.
use axum::{
body::Body,
extract::{FromRequest, Request},
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::de::DeserializeOwned;
use validator::Validate;
pub struct ValidatedJson<T>(pub T);
#[derive(Debug)]
pub struct ValidationError(validator::ValidationErrors);
impl IntoResponse for ValidationError {
fn into_response(self) -> Response {
(StatusCode::UNPROCESSABLE_ENTITY, Json(self.0)).into_response()
}
}
impl<T, S> FromRequest<S> for ValidatedJson<T>
where
T: DeserializeOwned + Validate,
S: Send + Sync,
{
type Rejection = Response;
async fn from_request(req: Request<Body>, state: &S) -> Result<Self, Self::Rejection> {
let Json(value) = Json::<T>::from_request(req, state)
.await
.map_err(|e| e.into_response())?;
value.validate().map_err(|e| {
(StatusCode::UNPROCESSABLE_ENTITY, Json(e)).into_response()
})?;
Ok(ValidatedJson(value))
}
}
Usage:
#[derive(Deserialize, Validate)]
struct CreateUser {
#[validate(email)]
email: String,
#[validate(length(min = 8))]
password: String,
}
async fn create_user(
State(db): State<DbPool>,
ValidatedJson(payload): ValidatedJson<CreateUser>,
) -> impl IntoResponse {
// payload.email and payload.password are already validated
}
The State Extractor and FromRef
In Axum 0.8, shared state goes through State<T>. The whole app state is set via .with_state(state) on the router.
The FromRef trait lets you extract sub-components of a larger state type without cloning the whole thing. Say your app state is:
#[derive(Clone)]
pub struct AppState {
pub db: DbPool,
pub redis: RedisPool,
pub config: Arc<Config>,
}
impl FromRef<AppState> for DbPool {
fn from_ref(state: &AppState) -> Self {
state.db.clone()
}
}
impl FromRef<AppState> for RedisPool {
fn from_ref(state: &AppState) -> Self {
state.redis.clone()
}
}
Now handlers can ask for just what they need:
// Only needs the DB
async fn get_user(State(db): State<DbPool>) -> impl IntoResponse { ... }
// Only needs Redis
async fn get_session(State(redis): State<RedisPool>) -> impl IntoResponse { ... }
This is cleaner than passing State(app): State<AppState> everywhere and manually destructuring.
Layers: Middleware Done Right
Axum uses tower’s ServiceBuilder to stack middleware. The single most important thing to understand: layers wrap from outside to inside. The first layer you add is the outermost — it runs first on the way in and last on the way out.
Basic Layer Stack
use axum::Router;
use tower::ServiceBuilder;
use tower_http::{
cors::CorsLayer,
request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer},
timeout::TimeoutLayer,
trace::TraceLayer,
};
use std::time::Duration;
fn build_router(state: AppState) -> Router {
let middleware = ServiceBuilder::new()
// Outermost: runs first in, last out
.layer(SetRequestIdLayer::x_request_id(MakeRequestUuid))
.layer(
TraceLayer::new_for_http()
.make_span_with(|req: &http::Request<_>| {
let request_id = req
.headers()
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.unwrap_or("unknown");
tracing::info_span!(
"http_request",
method = %req.method(),
uri = %req.uri(),
request_id = %request_id,
)
}),
)
.layer(TimeoutLayer::new(Duration::from_secs(30)))
.layer(PropagateRequestIdLayer::x_request_id())
.layer(CorsLayer::permissive()); // innermost for this stack
Router::new()
.route("/users", get(list_users).post(create_user))
.route("/users/:id", get(get_user).delete(delete_user))
.layer(middleware)
.with_state(state)
}
Gotcha: Ordering with auth layers matters enormously. If you put your auth layer outside the trace layer, you’ll get 401 responses with no trace span. If you put it inside, the trace span exists but lacks user context. The right order usually is: request ID → tracing → auth → rate limiting → business handlers.
Writing a Custom Layer
Tower middleware involves a bit of boilerplate, but the pattern is fixed. Here’s a request size limit layer (because axum::extract::DefaultBodyLimit is a per-route thing, not a global policy):
use std::{pin::Pin, task::{Context, Poll}};
use axum::http::StatusCode;
use futures_util::future::BoxFuture;
use tower::{Layer, Service};
#[derive(Clone)]
pub struct RequestSizeLayer {
max_bytes: usize,
}
impl RequestSizeLayer {
pub fn new(max_bytes: usize) -> Self {
Self { max_bytes }
}
}
impl<S> Layer<S> for RequestSizeLayer {
type Service = RequestSizeService<S>;
fn layer(&self, inner: S) -> Self::Service {
RequestSizeService { inner, max_bytes: self.max_bytes }
}
}
#[derive(Clone)]
pub struct RequestSizeService<S> {
inner: S,
max_bytes: usize,
}
impl<S, B> Service<http::Request<B>> for RequestSizeService<S>
where
S: Service<http::Request<B>, Response = http::Response<axum::body::Body>> + Clone + Send + 'static,
S::Future: Send + 'static,
B: Send + 'static,
{
type Response = S::Response;
type Error = S::Error;
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: http::Request<B>) -> Self::Future {
let max = self.max_bytes;
let content_length = req
.headers()
.get(http::header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<usize>().ok());
if let Some(len) = content_length {
if len > max {
let response = (StatusCode::PAYLOAD_TOO_LARGE, "Request body too large")
.into_response();
return Box::pin(async move { Ok(response) });
}
}
let future = self.inner.call(req);
Box::pin(async move { future.await })
}
}
This is the cost of working directly with tower. It’s verbose, but the generic bounds are doing real work — you get compile-time guarantees about service composition.
Error Handling: One Type to Rule Them All
The worst pattern I see in Axum codebases is scattered (StatusCode, String) returns from handlers. You lose structure, you can’t serialize errors consistently, and you can’t log them uniformly.
The right approach: one AppError enum, one IntoResponse impl, use ? everywhere.
The AppError Pattern
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("Record not found")]
NotFound,
#[error("Unauthorized")]
Unauthorized,
#[error("Forbidden")]
Forbidden,
#[error("Validation failed: {0}")]
Validation(String),
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Internal error")]
Internal(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
// Log internal errors before stripping the message
if matches!(self, AppError::Database(_) | AppError::Internal(_)) {
tracing::error!(error = %self, "Internal server error");
}
let (status, code, message) = match &self {
AppError::NotFound => (StatusCode::NOT_FOUND, "NOT_FOUND", self.to_string()),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "UNAUTHORIZED", self.to_string()),
AppError::Forbidden => (StatusCode::FORBIDDEN, "FORBIDDEN", self.to_string()),
AppError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, "VALIDATION_ERROR", msg.clone()),
// Never leak internal details to the client
AppError::Database(_) | AppError::Internal(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL_ERROR",
"Something went wrong".to_string(),
),
};
let body = Json(json!({
"error": {
"code": code,
"message": message,
}
}));
(status, body).into_response()
}
}
Handlers now look like:
async fn get_user(
State(db): State<DbPool>,
Path(user_id): Path<Uuid>,
) -> Result<Json<User>, AppError> {
let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", user_id)
.fetch_optional(&db)
.await? // sqlx::Error → AppError::Database via From
.ok_or(AppError::NotFound)?;
Ok(Json(user))
}
Gotcha: The #[from] sqlx::Error on AppError::Database means any SQL error, including unique constraint violations, maps to a 500. You almost certainly want to handle sqlx::Error::Database separately and check the constraint name to return a 409 Conflict for duplicates. Add a helper:
impl AppError {
pub fn from_sqlx(err: sqlx::Error) -> Self {
if let sqlx::Error::Database(ref db_err) = err {
// PostgreSQL unique violation code
if db_err.code().as_deref() == Some("23505") {
return AppError::Validation(
db_err.constraint()
.map(|c| format!("Duplicate value for {c}"))
.unwrap_or_else(|| "Duplicate value".into())
);
}
}
AppError::Database(err)
}
}
Handling Extractor Rejections Alongside AppError
When an extractor fails, axum returns the rejection directly, bypassing your AppError impl. If you want uniform error responses, you have two options:
Option 1: Make all your rejections return the same JSON structure (recommended — you control it).
Option 2: Use a custom HandleErrorLayer to catch extractor failures at the router level. This is useful when you use third-party extractors whose rejections you can’t change:
use axum::error_handling::HandleErrorLayer;
use tower::BoxError;
Router::new()
.route("/data", post(create_data))
.layer(
ServiceBuilder::new()
.layer(HandleErrorLayer::new(|error: BoxError| async move {
(StatusCode::INTERNAL_SERVER_ERROR, format!("Unhandled error: {error}"))
}))
)
Production Checklist
A few things that aren’t tutorial material but will burn you in prod:
Shutdown handling. Tokio’s graceful shutdown with axum:
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app)
.with_graceful_shutdown(async {
tokio::signal::ctrl_c().await.expect("failed to install CTRL+C handler");
})
.await?;
#[debug_handler] is not just for development. Put it on complex handlers during local work — the error messages go from a wall of trait bound failures to a pointed "this extractor consumes the body, it must be last". Remove it before shipping since it adds overhead.
Connection pool sizing. If you’re using sqlx, the default pool max of 10 will throttle you fast. Set it from the environment:
let pool = PgPoolOptions::new()
.max_connections(std::env::var("DB_POOL_MAX")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(20))
.connect(&database_url)
.await?;
Timeout your DB queries, not just HTTP handlers. TimeoutLayer on the router protects against slow handlers, but a hung DB query can hold a connection from the pool while the HTTP timeout fires and returns a 408 to the client. Use sqlx’s .timeout(Duration) on queries in critical paths.
Request ID propagation to downstream services. If you’re calling other services from handlers, pull the request ID from extensions and pass it through:
use tower_http::request_id::RequestId;
async fn proxy_handler(
Extension(request_id): Extension<RequestId>,
State(http_client): State<reqwest::Client>,
) -> Result<impl IntoResponse, AppError> {
let resp = http_client
.get("http://internal-service/api/data")
.header("x-request-id", request_id.header_value())
.send()
.await?;
// ...
}
Putting It All Together
Here’s a minimal but structurally complete main.rs that wires everything above:
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.json()
.init();
let db = PgPoolOptions::new()
.max_connections(20)
.connect(&std::env::var("DATABASE_URL")?)
.await?;
let state = AppState {
db,
config: Arc::new(Config::from_env()?),
};
let app = build_router(state);
let addr = std::env::var("BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:3000".into());
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!("Listening on {addr}");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c().await.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("Shutdown signal received, draining connections...");
}
Axum’s design pays off once you stop fighting the type system and start working with it. The extractor model is genuinely good — it moves validation to the edge of your handlers and keeps the business logic clean. Tower’s middleware story is verbose but composable in ways that Express middleware chains never were. And the error handling pattern described here eliminates a whole class of inconsistency bugs before they reach production.
The only real complaint about 0.8 is that the error messages for misused extractors are still rough even with #[debug_handler]. That’s a Rust compiler issue more than an Axum issue, and it’s slowly improving.