Handlers
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against axum 0.8.9 on Rust 1.99 (edition 2024) with tokio 1.53, checked on 2026-10-06. A handler
is the async function that runs for a matched route. axum’s whole developer experience rests on one trick: any function
whose arguments are extractors and whose output is a response type implements the Handler trait automatically. When
the trick fails, the compiler errors are long; this page explains the rules so you can read them.
Where it runs in the request
In the request lifecycle (tokio accepts
the connection, hyper parses the request, tower layers wrap the router, the router matches a route, middleware and
extractors run, the handler runs, the response travels back out), the handler is the innermost step: it runs after
routing, after every middleware layer and after all its extractors have succeeded, and its return value starts the
response’s way back out through the same layers. Everything on this page — what makes a function a handler, the
Send bound, #[debug_handler] — is about that one call.
What makes a function a handler
use axum::{
Json, Router,
extract::{Path, Query, State},
http::StatusCode,
routing::get,
};
use serde::{Deserialize, Serialize};
#[derive(Clone)]
struct AppState {
library_name: &'static str,
}
#[derive(Deserialize)]
struct ReviewQuery {
#[serde(default)]
page: u32,
}
#[derive(Serialize)]
struct ReviewPage {
library: &'static str,
isbn: String,
page: u32,
reviews: Vec<String>,
}
// three extractors in, a Result out
async fn list_reviews(
State(state): State<AppState>,
Path(isbn): Path<String>,
Query(q): Query<ReviewQuery>,
) -> Result<Json<ReviewPage>, StatusCode> {
if isbn.len() != 13 {
return Err(StatusCode::BAD_REQUEST);
}
Ok(Json(ReviewPage { library: state.library_name, isbn, page: q.page, reviews: vec!["A classic.".into()] }))
}
fn app() -> Router {
Router::new()
.route("/api/v1/books/{isbn}/reviews", get(list_reviews))
.with_state(AppState { library_name: "Bookshelf" })
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app()).await.unwrap();
}
Source: axum: handler
A function is a handler when all of the following hold:
-
It is an
async fn(or a function/closure returning a future). -
It takes zero to 16 arguments. Every argument except the last must implement
FromRequestParts(it reads only the request line and headers); the last may instead implementFromRequest, which can consume the body. See Extractors. -
Its output implements
IntoResponse(see Responses). -
The returned future is
Send, because tokio’s multi-thread scheduler may resume it on another worker thread. -
The function itself is
Clone + Send + Sync + 'static. TheSyncrequirement is new in axum 0.8; plain functions and closures that captureArcs satisfy it, closures capturing aCellorRcdo not.
axum implements Handler<T, S> for every function type that satisfies these bounds through a macro covering 0 to 16
arguments. The type parameter T is the tuple of extractor types (inferred), and S is the state type the handler
needs. The trait itself has one required method, call(self, req, state), which you never call directly.
Source: axum: Handler
Closures as handlers
Closures work wherever functions do, which is convenient for one-liners and for capturing configuration:
use std::sync::Arc;
use axum::{Router, extract::Path, routing::get};
#[tokio::main]
async fn main() {
let greeting = Arc::new(String::from("Welcome to Bookshelf"));
let app = Router::new()
.route("/", get(|| async { "home" }))
.route(
"/hello/{name}",
get({
let greeting = Arc::clone(&greeting);
move |Path(name): Path<String>| async move { format!("{greeting}, {name}!") }
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Source: axum: Using closure captures
The closure is cloned for every request, so capture Arcs rather than large values, and prefer State once more than
one handler needs the same data (see State & Dependency
Injection). The move ... async move pair is needed because the closure must own its captures ('static) and the
async block must own its copy of them.
#[debug_handler]
When a function fails one of the rules, the error appears where it is passed to get/post and mentions only the
Handler trait:
error[E0277]: the trait bound `fn(State<AppState>) -> impl Future<Output = &'static str> {lend}: Handler<_, _>`
is not satisfied
--> src/routes/loans.rs:18:59
|
18 | Router::new().route("/loans", post(lend))
| ---- ^^^^ the trait `Handler<_, _>` is not implemented for fn item ...
|
= note: Consider using `#[axum::debug_handler]` to improve the error message
#[axum::debug_handler] (axum’s macros feature) expands into separate compile-time checks for each argument, the
argument order, the return type and the future’s Send-ness, so the error points at the real cause. It accepts
state = AppState when the state type cannot be inferred from the arguments (for example when only a substate is
extracted through FromRef). It has no effect on behaviour or performance and only works on functions, not closures.
Source: axum::debug_handler
Message with #[debug_handler] |
Cause and fix |
|---|---|
|
an argument type is not an extractor; wrap it ( |
"`Json<_>` consumes the request body and thus must be the last argument to the handler function" |
a body extractor ( |
|
the return type is not a response; return |
"future cannot be sent between threads safely" |
a non- |
"Handlers cannot take more than 16 arguments" |
nest extractors in a tuple ( |
|
the router’s state type differs from the handler’s; see the state page |
The "future is not `Send`" pitfall
The most common handler error in real services comes from holding a std::sync::MutexGuard — or an Rc, a RefCell
borrow, or any other non-Send value — while awaiting:
use std::sync::{Arc, Mutex};
use axum::{Router, extract::State, routing::post};
#[derive(Clone, Default)]
struct AppState {
loans: Arc<Mutex<Vec<String>>>,
}
async fn notify(_member: &str) {}
#[axum::debug_handler]
async fn lend(State(state): State<AppState>) -> &'static str {
let mut loans = state.loans.lock().unwrap();
loans.push("Dune".into());
notify("member@example.com").await; // the guard is still alive here
"lent"
}
fn main() {
let _app: Router = Router::new().route("/loans", post(lend)).with_state(AppState::default());
// error: future cannot be sent between threads safely
// = help: within `impl Future<Output = &'static str>`, the trait `Send` is not implemented for
// `std::sync::MutexGuard<'_, Vec<String>>`
// error[E0277]: the trait bound `fn(State<AppState>) -> ... {lend}: Handler<_, _>` is not satisfied
}
A MutexGuard must be released on the thread that acquired it, so it is not Send; if the task were moved to another
worker while suspended at the .await, that rule would break. The compiler therefore refuses to make the future Send.
The fix is almost never a tokio::sync::Mutex; it is to end the critical section before awaiting:
use std::sync::{Arc, Mutex};
use axum::{Router, extract::State, routing::post};
#[derive(Clone, Default)]
struct AppState {
loans: Arc<Mutex<Vec<String>>>,
}
async fn notify(_member: &str) {}
async fn lend(State(state): State<AppState>) -> &'static str {
{
let mut loans = state.loans.lock().expect("loans lock poisoned");
loans.push("Dune".into());
} // guard dropped here, before any .await
notify("member@example.com").await;
"lent"
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/loans", post(lend)).with_state(AppState::default());
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
An inner block, a helper function that does the locked work synchronously, or an explicit drop(guard) all work (the
block is the most robust: drop alone does not always convince the compiler in older code). Use tokio::sync::Mutex
only when the lock must genuinely be held across an .await, such as serialising access to a single connection. The
Shared State, Send/Sync & Atomics page covers
Send and Sync in general.
Handlers with and without state
A handler that extracts State<S> needs a router that has been given an S. Router::with_state supplies it for every
route; a single handler can be turned into a self-contained service with Handler::with_state(state), which returns a
HandlerService usable with route_service or nest_service.
A handler that needs no state can be used as a service directly through HandlerWithoutStateExt:
use axum::{
Router,
handler::HandlerWithoutStateExt,
http::StatusCode,
response::IntoResponse,
};
use tower_http::services::ServeDir;
async fn missing_asset() -> impl IntoResponse {
(StatusCode::NOT_FOUND, "no such file")
}
#[tokio::main]
async fn main() {
// ServeDir's fallback must be a Service; `into_service` adapts the handler.
let static_files = ServeDir::new("static").not_found_service(missing_asset.into_service());
let app = Router::new().nest_service("/static", static_files);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Source: axum: HandlerWithoutStateExt
HandlerWithoutStateExt also offers into_make_service and into_make_service_with_connect_info, so a single handler
can be served without a router at all — axum::serve(listener, handler.into_make_service()) — which is handy for a
one-endpoint redirect server (see TLS & HTTPS).
Keep handlers thin
A handler’s job is translation: extract and validate input, call a service function, map the result to a response. Business rules belong in plain functions that know nothing about axum, so they can be unit-tested without a router and reused by background jobs and CLIs (see Project Structure). A handler longer than a screen is usually doing a service’s work.
Where the books are outdated
Yoon (Beginning Axum, chapter 3) writes handlers with unwrap() on database results and request data. A panic in a
handler does not crash the server — it unwinds only the task serving that connection — but the client gets a dropped
connection instead of a response, and nothing useful reaches the logs unless a panic hook or CatchPanicLayer is
installed. Return Result<T, AppError> and use ? instead, keeping
expect for start-up code (see Error Handling). axum 0.7 handlers did not need
to be Sync; a 0.7 handler that captures a non-Sync value in a closure fails to compile on 0.8.