Building a REST API with Axum: Tower Services, Extractors, State and Error Handling

Key takeaways

Axum is a Rust web framework on Tokio, Tower, and Hyper. This guide walks through routing, extractors, middleware, state, errors, WebSocket, and REST API structure in one flow.

What this article covers

Axum is a popular framework for HTTP services in Rust. It sits on the Tokio async runtime, Tower’s Service layer, and the Hyper HTTP implementation, and its hallmark is the Extractor model—“parse the request with types.” Here we cover routing and handlers, extractors and middleware, application state, error responses, WebSocket, and a practical REST API layout in one place. If you have already read Rust async & Tokio, that background maps directly to learning Axum.


What is Axum?

Stack

Axum does not reimplement everything itself. Requests arrive through Hyper, async execution is Tokio’s job, and cross-cutting concerns such as middleware, timeouts, and retries compose via Tower’s Service and Layer. Long term, understanding Tower patterns and async Rust together pays off more than learning “Axum only.”

The key abstraction is Tower’s Service trait: something that takes a request and returns a future of a response. An Axum Router is a Service, each handler is turned into one, and each middleware is a Layer that wraps one Service in another. That uniformity is why a timeout, rate limiter or compression layer written for Tower works with Axum, Tonic (gRPC) and plain Hyper alike. It is also why Axum’s error messages can be intimidating at first: when something does not fit, the compiler reports it in terms of trait bounds on Service and Handler rather than in terms of your route.

Design philosophy

  • Type-safe routing: Combine path parameters, query strings, and bodies with extractors so parsing rules can be checked at compile time.
  • Nested routers: Split APIs by module with merge and nest on Router.
  • Consistent middleware: Tower layers plug in directly, so CORS, tracing, compression, and more attach the same way.

When to choose Axum

It fits HTTP, JSON, and streaming-centric services: microservice APIs, BFFs, internal admin tools, and realtime endpoints mixing WebSocket. If you only need a classic multi-page server driven by a template engine and SSR, other stacks may be simpler.


Project setup and dependencies

New project

cargo new axum-demo --bin
cd axum-demo

Sample Cargo.toml

Below is a minimal setup for REST, CORS, logging, and WebSocket in one go. In production, pin versions and run cargo audit regularly.

[package]
name = "axum-demo"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = { version = "0.7", features = ["macros", "ws"] }
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.5", features = ["cors", "trace"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4", "serde"] }

features = ["macros"] enables helper macros, most usefully #[axum::debug_handler] (which turns opaque “Handler is not implemented” errors into readable ones) and #[derive(FromRequest)]/#[derive(FromRef)]; ws is required for WebSocket handlers. Axum has no route-attribute macros like Actix’s #[get("/")]: routes are always declared on the Router.

Version alignment matters more than usual here, because Axum, Hyper, Tower and tower-http evolve together. Axum 0.7 moved to Hyper 1.0 and pairs with tower-http 0.5 or 0.6; mixing in a crate built for Axum 0.6 produces errors about two different versions of http::Request that look impossible until you run cargo tree -d. Axum 0.8 (released in 2025) changed path parameter syntax from /:id to /{id}; an 0.8 router panics at startup on the old syntax, so check which version a snippet targets. The examples here use 0.7.


Minimal server and routing

Hello World

Attach paths and methods to Router, bind with TcpListener, and pass the app to axum::serve. Using tokio::main for an async entry point is the usual pattern.

use axum::{routing::get, Router};

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(|| async { "Hello, Axum" }));

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();
    axum::serve(listener, app).await.unwrap();
}

The closure passed to get is an async block, so handlers return a Future. The return type must implement IntoResponse; a string slice becomes a 200 OK text response automatically.

The unwrap() on bind is acceptable in an example but produces an unhelpful panic in production when the port is already taken (Address already in use). Binding to 127.0.0.1 also means the server is only reachable from the same machine; inside a Docker container that makes it unreachable from outside, and the fix is to bind 0.0.0.0. #[tokio::main] starts a multi-threaded runtime with one worker per CPU core by default, so handlers run concurrently on several threads, which is why everything shared between them must be Send + Sync.

Multiple routes and HTTP methods

use axum::{
    routing::{get, post},
    Router,
};

async fn list_users() -> &'static str {
    "user list"
}

async fn create_user() -> &'static str {
    "user created"
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/users", get(list_users).post(create_user));

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();
    axum::serve(listener, app).await.unwrap();
}

To attach both GET and POST on the same path, chain with get(...).post(...) on one line. For REST APIs, grouping by resource keeps routes readable.


Handlers and extractors

What is an extractor?

In Axum, an extractor “pulls values out of the request and turns them into types.” Argument order and types define the parsing rules. For example, Path handles path parameters, Query the query string, and Json the JSON body.

Mechanically, any type that implements FromRequestParts (reads only the method, URI and headers) or FromRequest (may also consume the body) can be a handler argument. Axum runs the extractors before calling your function, and if one fails, its rejection is returned as the response and your handler never runs. That is what makes handlers short: a function that receives Json<CreateUser> can assume the body was valid JSON of the right shape. The flip side is that rejections have default status codes and plain-text bodies, so an API that promises JSON errors everywhere needs to customize them, either by wrapping the extractor in its own type or by taking Result<Json<T>, JsonRejection> as the argument and mapping the error yourself.

Path and Json

use axum::{
    extract::Path,
    routing::{get, post},
    Json, Router,
};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct CreateUser {
    name: String,
}

#[derive(Serialize)]
struct User {
    id: u64,
    name: String,
}

async fn get_user(Path(user_id): Path<u64>) -> Json<User> {
    Json(User {
        id: user_id,
        name: "demo".into(),
    })
}

async fn create_user(Json(payload): Json<CreateUser>) -> Json<User> {
    Json(User {
        id: 1,
        name: payload.name,
    })
}

fn user_routes() -> Router {
    Router::new()
        .route("/users/:id", get(get_user))
        .route("/users", post(create_user))
}

Path must match segment names (:id) and types. If u64 parsing fails, Axum responds with 400 Bad Request; for custom types or validation, consider a serde pipeline or a custom extractor. Json handles Content-Type: application/json and body parsing; deserialization failures are also client errors.

The exact status codes are worth knowing because clients depend on them. A missing or wrong Content-Type header yields 415 Unsupported Media Type, syntactically invalid JSON yields 400, and valid JSON with the wrong shape (a missing field, a string where a number is expected) yields 422 Unprocessable Entity. A frequent confusion in testing is a curl -d '{"name":"x"}' request failing with 415: curl -d sends application/x-www-form-urlencoded unless you add -H 'Content-Type: application/json'. Note also that user_routes() returns a Router with no state, so it can be merged into any app; routers that need state are typed Router<AppState>.

Combining extractors

A handler can take several extractors as separate arguments, for example Path, then Query, then HeaderMap, and Axum runs them in order. Body-consuming extractors (Json, Form, String, Bytes, Request) can appear only once, and it must be the last argument, because the body is a stream that can be read only once. Putting Json<T> before State or Path is the single most common reason a handler “does not implement Handler”, and the error message does not say so; adding #[axum::debug_handler] above the function makes the compiler point at the offending argument.

use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::Json;

#[derive(serde::Deserialize)]
struct Pagination { page: Option<u32> }

// Parts-only extractors first, the body extractor last
async fn update_user(
    State(state): State<AppState>,
    Path(id): Path<u64>,
    Query(p): Query<Pagination>,
    headers: HeaderMap,
    Json(body): Json<CreateUser>,
) -> StatusCode { /* ... */ StatusCode::OK }

Middleware

Tower Layer

Axum wraps Tower’s Layer with Router::layer. CORS, request logging, timeouts, and more usually come from tower-http.

use axum::Router;
use tower_http::cors::{Any, CorsLayer};
use tower_http::trace::TraceLayer;

fn app() -> Router {
    Router::new()
        .route("/health", axum::routing::get(|| async { "ok" }))
        .layer(
            CorsLayer::new()
                .allow_origin(Any)
                .allow_methods(Any)
                .allow_headers(Any),
        )
        .layer(TraceLayer::new_for_http())
}

In production, prefer an explicit allowlist of origins over allow_origin(Any). TraceLayer integrates with tracing to log method, path, and status code.

Layer order is easy to get backwards. Each .layer() call wraps everything defined before it, so the last layer added is the outermost and sees the request first. In this example TraceLayer wraps CorsLayer, which means CORS preflight requests are traced too. .layer() also applies only to routes (and a fallback) added before it; a route added afterwards is not wrapped. If ordering becomes confusing, tower::ServiceBuilder lists layers top to bottom in the order a request passes through them.

Two CORS details cause most “CORS error” reports. allow_origin(Any) cannot be combined with allow_credentials(true), because the specification forbids a wildcard origin on credentialed requests, and tower-http panics at startup if you try. And a CORS layer only adds headers; if the handler returns a 500 before the layer runs, or a reverse proxy answers first, the browser reports a CORS failure that is really a server error. Check the network tab for the actual status code.

The TraceLayer only produces output once a subscriber is installed, for example tracing_subscriber::fmt().with_env_filter(EnvFilter::from_default_env()).init(); at the top of main and RUST_LOG=tower_http=debug in the environment. Forgetting this is why many first Axum servers seem to log nothing.

Custom middleware

axum::middleware::from_fn can turn an async function into a layer—useful for auth headers, request IDs, rate limiting, and more.

use axum::{
    body::Body,
    extract::Request,
    middleware::Next,
    response::Response,
};

async fn inject_request_id(mut req: Request, next: Next) -> Response {
    let id = uuid::Uuid::new_v4().to_string();
    req.headers_mut().insert("x-request-id", id.parse().unwrap());
    next.run(req).await
}

Next forwards the request to the next layer or handler. To post-process the response, add headers after next.run returns. Attach it with .layer(axum::middleware::from_fn(inject_request_id)); if the middleware needs application state, from_fn_with_state(state.clone(), f) passes it in, and the function can then take State<AppState> as an extractor before the Request.

The unwrap() on id.parse() is safe only because a UUID string is always a valid header value. For values that come from the client or a config file, HeaderValue::from_str can fail, and unwrapping it lets any caller crash the task with a malformed value. A middleware can also return early without calling next.run, which is how authentication middleware rejects a request with 401: the handler and every inner layer never see it.


State management

AppState pattern

To share a DB pool, configuration, or clients app-wide, implement Clone on a struct and inject it with Router::with_state. Wrap expensive resources in Arc.

use std::sync::Arc;
use axum::{extract::State, routing::get, Router};

#[derive(Clone)]
struct AppState {
    db: Arc<String>, // in real code, e.g. sqlx::PgPool
}

async fn handler(State(state): State<AppState>) -> String {
    format!("db = {}", state.db)
}

fn app(state: AppState) -> Router {
    Router::new()
        .route("/", get(handler))
        .with_state(state)
}

The type in a handler’s State<T> must match the router’s state type, or be derivable from it through the FromRef trait (which lets a handler extract just State<PgPool> from a larger AppState). A mismatch is a compile error, usually reported as the handler not implementing Handler<_, _>. If sub-routers use different state types, merge generics get awkward; a single AppState with per-module fields is usually simpler.

AppState is cloned for every request that extracts it, which is why expensive members are wrapped in Arc (or are already cheap handles, as sqlx::PgPool is: cloning a pool clones a reference to the same pool). A common beginner error is putting a plain HashMap in the state and expecting writes to persist; each request gets its own clone, so updates vanish. Mutable shared data needs Arc<Mutex<...>> or Arc<RwLock<...>>, as in the CRUD example below. The type returned by with_state is Router<()>, meaning “no state still missing”; axum::serve accepts only that, so forgetting with_state shows up as a type error on the serve line rather than on the route that needs state.

Sub-routes and state

When you nest under /api, the same State still applies. If you version APIs, splitting Router definitions across files and mergeing them helps maintenance.


Error handling

Result and IntoResponse

For handlers to return Result<impl IntoResponse, E>, you need E: IntoResponse. A common pattern is one project-wide error type with IntoResponse implemented.

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;

pub struct ApiError {
    status: StatusCode,
    message: String,
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let body = Json(json!({
            "error": self.message,
        }));
        (self.status, body).into_response()
    }
}

pub type ApiResult<T> = Result<T, ApiError>;

Then handlers can use ? and keep HTTP status and JSON shape consistent. For ? to work on errors from other libraries, ApiError needs From implementations, for example impl From<sqlx::Error> for ApiError that maps RowNotFound to 404 and everything else to 500. Without them, the compiler reports that ? cannot convert the error, which is the usual prompt to write the conversion once rather than map_err in every handler.

Axum’s own rule is that handlers are infallible at the framework level: whatever you return, including an Err, must become a response. That is different from frameworks where an uncaught exception turns into a generic 500 somewhere up the stack. In Axum a panic inside a handler aborts that request’s task, and the client typically sees the connection close without a response unless you add tower_http::catch_panic::CatchPanicLayer.

Integrating anyhow and thiserror

Model domain errors with thiserror and map them to ApiError at boundaries. Avoid exposing anyhow::Error directly to clients—use generic messages on 500 and log details server-side.

Fallback handlers

Use Router::fallback to unify 404 JSON responses, or when serving an SPA to always return index.html as needed.


WebSocket

Upgrade flow

Axum’s WebSocket path upgrades HTTP to a bidirectional stream. Enter the handler with the WebSocketUpgrade extractor and pass the real socket work to on_upgrade.

use axum::{
    extract::ws::{Message, WebSocket, WebSocketUpgrade},
    response::IntoResponse,
    routing::get,
    Router,
};

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 let Message::Text(t) = msg {
            let _ = socket.send(Message::Text(t)).await;
        }
    }
}

fn ws_routes() -> Router {
    Router::new().route("/ws", get(ws_handler))
}

In production, also design ping/pong timeouts, message size limits, and authentication (query token or cookie). Long-lived connections must align with load balancer idle timeouts.

Authentication belongs in ws_handler, before on_upgrade: that function still has the HTTP request, so it can take extractors such as State, Query or a cookie jar and return 401 instead of upgrading. Once on_upgrade has been called, you only have a socket. The echo loop also shows a limitation: it cannot send and receive independently. For a chat or push feature, split the socket with socket.split() (from futures::StreamExt) into a sender and a receiver, run each in its own task, and feed outgoing messages to the sender through a tokio::sync::mpsc or broadcast channel. When one of the two tasks ends, abort the other, or a disconnected client leaves a task running forever. The loop exits quietly on Some(Err(_)) because while let Some(Ok(msg)) stops on the first error, which is usually the desired behavior for a dropped connection.


Production REST API structure

Layered layout

The sketch below assumes router / handler / service (domain) / repository instead of one giant file. Small projects can keep logic in handlers; as tests and churn grow, a service layer pays off.

  • Router: URLs, methods, middleware only
  • Handler: Extractor wiring, StatusCode, DTO mapping
  • Service: Pure logic and transaction boundaries
  • Repository: DB and external APIs

CRUD sketch

use axum::{
    extract::{Path, State},
    http::StatusCode,
    routing::{get, post, delete},
    Json, Router,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};

#[derive(Clone, Default)]
struct AppState {
    items: Arc<RwLock<HashMap<u64, Item>>>,
    next_id: Arc<RwLock<u64>>,
}

#[derive(Clone, Serialize)]
struct Item {
    id: u64,
    name: String,
}

#[derive(Deserialize)]
struct CreateItem {
    name: String,
}

async fn list_items(State(state): State<AppState>) -> Json<Vec<Item>> {
    let map = state.items.read().unwrap();
    Json(map.values().cloned().collect())
}

async fn get_item(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> Result<Json<Item>, StatusCode> {
    let map = state.items.read().unwrap();
    map.get(&id).cloned().map(Json).ok_or(StatusCode::NOT_FOUND)
}

async fn create_item(
    State(state): State<AppState>,
    Json(payload): Json<CreateItem>,
) -> (StatusCode, Json<Item>) {
    let mut id_lock = state.next_id.write().unwrap();
    let id = *id_lock;
    *id_lock += 1;
    let item = Item {
        id,
        name: payload.name,
    };
    state.items.write().unwrap().insert(id, item.clone());
    (StatusCode::CREATED, Json(item))
}

async fn delete_item(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> StatusCode {
    match state.items.write().unwrap().remove(&id) {
        Some(_) => StatusCode::NO_CONTENT,
        None => StatusCode::NOT_FOUND,
    }
}

fn api_router(state: AppState) -> Router {
    Router::new()
        .route("/items", get(list_items).post(create_item))
        .route("/items/:id", get(get_item).delete(delete_item))
        .with_state(state)
}

Here the in-memory HashMap is demo-only; real services keep this data in a database. The std::sync::RwLock itself is fine in async code as long as the guard is never held across an .await, which is the case in every handler above: each lock is taken, used and dropped synchronously. Tokio’s own documentation recommends the standard library locks for short critical sections like these, because tokio::sync::RwLock is slower and only needed when you must hold the lock while awaiting. If you do hold a std guard across an .await, the compiler stops you, because the guard is not Send and the handler’s future no longer satisfies Axum’s bounds, which is a surprisingly helpful error once you know what it means.

Two smaller issues are worth fixing before copying this. next_id is a separate lock from items, so the ID allocation and the insert are not one atomic step; a plain AtomicU64 with fetch_add is simpler and correct for ID generation. And .unwrap() on a lock panics if the lock is poisoned, which happens when a previous holder panicked; in a server that turns one bug into a failure of every later request touching that map. The right concurrency model depends on domain needs and measurements.

Validation and boundaries

Validate input with serde constraints, the validator crate, or custom extractors. Validate once at the boundary and convert to internal domain types so invalid state cannot spread deep into the stack.


Operations, performance, pitfalls

Blocking calls

Running synchronous file I/O or CPU-heavy work for a long time on a Tokio worker can starve other requests. If you must block, use tokio::task::spawn_blocking or a dedicated thread pool.

The symptom is distinctive: latency for all endpoints rises together, including trivial ones like /health, while CPU usage may look moderate. Each blocked worker thread stops polling every other task scheduled on it. Password hashing with bcrypt or Argon2 is the classic example in web services, since it is deliberately slow (tens to hundreds of milliseconds); calling it directly in a login handler can stall a small runtime under a burst of logins. spawn_blocking moves the call to a separate pool designed for blocking work, and for sustained CPU-bound workloads a rayon pool is a better fit, since the blocking pool is sized for waiting, not for computing.

Timeouts and body size

Align timeouts and max body size between your reverse proxy (Nginx, Cloudflare) and the app. Infra-level limits are often the real defense line, not Axum/Hyper settings alone.

Axum does have its own defaults: body-reading extractors such as Json and Bytes reject bodies over 2 MB with 413 Payload Too Large, which you raise per route with DefaultBodyLimit::max(...) for upload endpoints. There is no request timeout by default, so a slow database call can hold a request indefinitely; tower_http::timeout::TimeoutLayer adds one. Graceful shutdown is also opt-in: axum::serve(listener, app).with_graceful_shutdown(signal) stops accepting connections on SIGTERM and lets in-flight requests finish, which matters for rolling deployments on Kubernetes, where pods are terminated routinely.

Observability

Structured logging with tracing, OpenTelemetry, and /health and /ready endpoints are close to mandatory in operations. Adding a request ID to error responses speeds up incident analysis.


Troubleshooting

SymptomCommon causeWhat to check
the trait bound ... is not satisfiedExtractor order or type mismatchHandler argument types vs with_state generics
Only 404snest paths or duplicate slashesMixing "/api" vs "/api/"
WebSocket drops immediatelyProxy does not support upgradeHTTP/1.1 upgrade and header forwarding
Body parse failuresContent-Type or JSON shapeClient sends application/json
Handler “is not Send” / future not SendMutexGuard, Rc or RefCell held across .awaitDrop the guard before awaiting, use Arc
Startup panic about path syntaxAxum 0.8 with /:id routesUse /{id} in 0.8

When I first wrote Axum services, most of the time lost went into the second and fifth rows: trait-bound errors that were really an extractor in the wrong position or a lock guard living across an .await. #[axum::debug_handler] resolves most of them in seconds, so it is worth adding by reflex whenever a handler refuses to compile, and removing once it does.


Testing handlers without starting a server

Once the router compiles, the next step that pays off is tests that exercise the real routing, extractors and middleware. A Router is a Tower Service, so a test can build the same router the binary uses, create an http::Request, and call router.oneshot(request).await (with tower::ServiceExt in scope) to get the response. No port is bound and no HTTP client is involved, which keeps the tests fast and lets them run in parallel.

Build the router in a function that takes its state as a parameter, so tests can pass an in-memory or test database instead of the production pool. This also catches the layer-ordering mistakes described earlier, because the middleware runs in the test exactly as it does in production.


Frequently Asked Questions (FAQ)

Q. Why does the compiler say my function does not implement Handler?

A. The most common causes are a body-consuming extractor such as Json, String or Bytes that is not the last argument, a future that is not Send because it holds something like a std::sync::MutexGuard or Rc across an .await, or a return type that does not implement IntoResponse. Add #[axum::debug_handler] (from the macros feature) to the function to get an error message that points at the actual problem.