Axum / Chapter 1 of 4

Routing, extractors, and responses

Build Axum routes with Path, Query, Json, and State extractors. Rust examples cover handlers, validation, responses, and error handling.

9 topicsUpdated
01 / 31

Start here#

Axum turns an incoming HTTP request into values your function can use, runs your async function, then turns its return value into an HTTP response.

01Router matchesPath + HTTP method choose a handler.
02Extractors parsePath, query, headers, state, and body become Rust values.
03Handler runsYour async function executes application logic.
04Response buildsThe return value implements IntoResponse.
Rust words used on this page

A struct groups data, an enum describes one of several cases, a trait describes behavior, and async fn can pause at .await without blocking an operating-system thread.

Project setup

Shellterminal
cargo new notes-api
cd notes-api
TOMLCargo.toml
[package]
name = "notes-api"
version = "0.1.0"
edition = "2024"

[dependencies]
axum = { version = "0.8.9", features = ["macros", "multipart", "ws", "http2"] }
tokio = { version = "1", features = ["full"] }
tower = { version = "0.5", features = ["util", "timeout", "load-shed"] }
tower-http = { version = "0.6", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
futures-util = "0.3"
tokio-stream = "0.1"

[dev-dependencies]
http-body-util = "0.1"

Smallest useful server

Rustsrc/main.rs
use axum::{routing::get, Router};

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

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();

    axum::serve(listener, app).await.unwrap();
}

#[tokio::main] starts the async runtime. Router stores routes. TcpListener listens for network connections. axum::serve connects the listener to the app.

02 / 31

Routing#

A route combines a path with one or more HTTP methods. Axum gives fixed paths priority over dynamic paths.

Rustcommon routes
use axum::{
    extract::Path,
    routing::{delete, get, patch, post},
    Router,
};

let app = Router::new()
    .route("/health", get(health))
    .route("/notes", get(list_notes).post(create_note))
    .route("/notes/{id}", get(show_note).patch(update_note).delete(delete_note))
    .route("/assets/{*path}", get(asset));

async fn health() -> &'static str { "ok" }
async fn list_notes() {}
async fn create_note() {}
async fn show_note(Path(id): Path<u64>) {}
async fn update_note(Path(id): Path<u64>) {}
async fn delete_note(Path(id): Path<u64>) {}
async fn asset(Path(path): Path<String>) {}
Static

/health

Matches one exact path.

Capture

/notes/{id}

Captures one segment as id.

Wildcard

/assets/{*path}

Captures the remaining non-empty path.

Every method router

FunctionMatchesTypical purpose
getGET; Axum also supplies HEAD behaviorRead a resource
postPOSTCreate or perform an action
putPUTReplace a resource
patchPATCHPartially update a resource
deleteDELETEDelete a resource
headHEADHeaders without a response body
optionsOPTIONSCapabilities and CORS preflight
connectCONNECTHTTP tunnels and proxies
traceTRACEHTTP diagnostics; rarely enabled
anyEvery methodProtocol upgrades or catch-all logic
onA MethodFilter combinationCustom groups such as GET | HEAD

Split and combine routers

Rustnest + merge + fallback
use axum::{http::StatusCode, routing::get, Router};

fn api_routes() -> Router {
    Router::new()
        .route("/notes", get(list_notes))
        .route("/users", get(list_users))
}

let status_routes = Router::new().route("/health", get(health));

let app = Router::new()
    .nest("/api/v1", api_routes())
    .merge(status_routes)
    .fallback(|| async {
        (StatusCode::NOT_FOUND, "route not found")
    });

nest adds a path prefix and strips it before the inner router sees the URI. OriginalUri preserves the URI received from the client. merge combines routers without adding a prefix.

03 / 31

Handlers#

A handler is normally an async fn. Its arguments are extractors and its return value must implement IntoResponse.

Rusthandler anatomy
async fn update_note(
    State(state): State<AppState>, // request parts: does not consume body
    Path(id): Path<u64>,            // request parts
    headers: HeaderMap,              // request parts
    Json(input): Json<UpdateNote>,   // body: must be last
) -> Result<Json<Note>, AppError> {
    let note = state.notes.update(id, input).await?;
    Ok(Json(note))
}
Extractor order matters

Only the final argument may consume the request body. Put Json, Form, String, Bytes, Request, or another FromRequest extractor last. Earlier arguments must implement FromRequestParts.

Handler rules

Rustbetter compiler errors
use axum::debug_handler;

#[debug_handler]
async fn difficult_handler(
    Json(input): Json<CreateNote>,
) -> Result<Json<Note>, AppError> {
    // The macro reports why this function is not a valid Handler.
    todo!()
}

debug_handler and debug_middleware require Axum’s macros feature and are most useful while fixing type errors.

04 / 31

Extractors#

An extractor validates and converts part of a request. If it fails, Axum returns its rejection response and never calls the handler.

ExtractorReadsUse it forBody?
Path<T>Route captures/notes/{id} values deserialized with SerdeNo
Query<T>Query string?page=2&tag=rustNo
Json<T>JSON bodyTyped API inputYes
Form<T>Query on GET/HEAD; URL-encoded body otherwiseHTML formsSometimes
State<T>Router stateDatabase pools, configuration, clientsNo
Extension<T>Request extensionsValues inserted by middlewareNo
HeaderMapAll headersAuthorization and metadataNo
Method / Uri / VersionRequest lineHTTP metadataNo
ConnectInfo<T>Connection extensionPeer address or custom connection dataNo
MatchedPathMatched route templateMetrics labels such as /notes/{id}No
OriginalUriOriginal URIURI before nested prefixes were strippedNo
NestedPathNested prefixPath at which a router was nestedNo
RawQueryUnparsed queryExact raw query textNo
RawPathParamsUndeserialized capturesIterating raw key/value path pairsNo
BytesBuffered bodyBinary dataYes
StringBuffered UTF-8 bodyPlain textYes
BodyStreaming bodyForwarding or incremental processingYes
RequestWhole requestFull low-level controlYes
RawFormRaw URL-encoded dataManual form parsingYes
MultipartMultipart bodyFiles and mixed form fieldsYes
WebSocketUpgradeUpgrade requestSwitching HTTP to WebSocketYes

Path + query + headers together

Rustcustom list request
use axum::{
    extract::{Path, Query},
    http::{header::USER_AGENT, HeaderMap},
    Json,
};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct Filters {
    page: Option<u32>,
    archived: Option<bool>,
}

#[derive(Serialize)]
struct ResultPage {
    notebook: String,
    page: u32,
    client: String,
}

async fn list_notebook(
    Path(notebook): Path<String>,
    Query(filters): Query<Filters>,
    headers: HeaderMap,
) -> Json<ResultPage> {
    let client = headers
        .get(USER_AGENT)
        .and_then(|value| value.to_str().ok())
        .unwrap_or("unknown")
        .to_owned();

    Json(ResultPage {
        notebook,
        page: filters.page.unwrap_or(1),
        client,
    })
}

Optional input and custom rejection handling

Option<T>

The supported absence of an extractor becomes None. Not every failure is treated as absence; each extractor controls this through OptionalFromRequest or OptionalFromRequestParts.

Result<T, T::Rejection>

Your handler receives either the value or its rejection, letting you replace Axum’s default error response locally.

05 / 31

JSON & forms#

The same wrapper can often be an extractor on input and a response on output. Serde performs the conversion between wire data and Rust structs.

RustJSON request + JSON response
use axum::{http::StatusCode, Json};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct CreateNote {
    title: String,
    body: String,
}

#[derive(Serialize)]
struct Note {
    id: u64,
    title: String,
    body: String,
}

async fn create_note(
    Json(input): Json<CreateNote>,
) -> (StatusCode, Json<Note>) {
    let note = Note {
        id: 42,
        title: input.title,
        body: input.body,
    };
    (StatusCode::CREATED, Json(note))
}

Json<T> requires a JSON content type, valid JSON syntax, and fields that deserialize into T. Json::from_bytes handles special cases where you have already buffered the body.

RustHTML form
use axum::{response::Html, Form};
use serde::Deserialize;

#[derive(Deserialize)]
struct Login {
    email: String,
    password: String,
}

async fn login(Form(input): Form<Login>) -> Html<String> {
    Html(format!("<p>Signed in as {}</p>", input.email))
}
Form behavior changes by method

For GET and HEAD, Form<T> reads the query string. For other methods it reads an application/x-www-form-urlencoded request body. It does not parse multipart file uploads; use Multipart.

06 / 31

Request validation#

Serde parses input into Rust types. Validation checks application rules such as length, range, and format. Run both before database writes or other business logic.

Use Garde with axum-valid

Garde declares rules on Rust fields. axum-valid wraps Axum extractors and returns 400 Bad Request when those rules fail. The handler runs only after parsing and validation succeed.

1. Add the validation crates

TOMLCargo.toml
[dependencies]
garde = { version = "0.23", features = ["derive", "email"] }
axum-valid = { version = "0.25", default-features = false, features = ["basic", "garde"] }

2. Put rules on the input type

Rustvalidated JSON
use axum::{http::StatusCode, Json};
use axum_valid::Garde;
use garde::Validate;
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize, Validate)]
struct CreateAccount {
    #[garde(email)]
    email: String,

    #[garde(length(min = 12, max = 128))]
    password: String,

    #[garde(range(min = 13, max = 120))]
    age: u8,
}

#[derive(Serialize)]
struct CreatedAccount {
    id: u64,
    email: String,
}

async fn create_account(
    Garde(Json(input)): Garde<Json<CreateAccount>>,
) -> (StatusCode, Json<CreatedAccount>) {
    // This point is reached only when JSON parsing and every rule passed.
    (
        StatusCode::CREATED,
        Json(CreatedAccount { id: 42, email: input.email }),
    )
}
Shape

Serde

Required fields, field types, JSON syntax, renaming, defaults, and unknown-field policy.

Rules

Garde

Length, ranges, email, URL, patterns, nested values, conditional rules, and custom functions.

Extraction

axum-valid

Composes validation with Json, Query, Path, Form, and optional ecosystem extractors.

Nested, list, and custom rules

Rustmore Garde rules
#[derive(Deserialize, Validate)]
struct Address {
    #[garde(length(min = 2, max = 80))]
    city: String,
}

#[derive(Deserialize, Validate)]
struct OrderInput {
    #[garde(dive)]
    shipping: Address,

    // The vector needs 1..=20 items; every item must be non-empty.
    #[garde(length(min = 1, max = 20), inner(length(min = 1)))]
    product_codes: Vec<String>,

    #[garde(custom(not_reserved))]
    display_name: String,
}

fn not_reserved(value: &str, _ctx: &()) -> garde::Result {
    match value.to_ascii_lowercase().as_str() {
        "admin" | "root" => Err(garde::Error::new("name is reserved")),
        _ => Ok(()),
    }
}
Validation is not sanitization or authorization

Keep raw values as data, encode them for the output context, bind them as database parameters, and still check who may perform the action. Normalize only by an explicit rule—for example, trim an email before uniqueness checks—and keep validation error responses free of internal details.

07 / 31

Responses#

Axum accepts anything implementing IntoResponse. Tuples let you combine a status, headers, extensions, and a body without manually building an HTTP response.

Return valueResult
()Empty 200 response
&str / StringPlain text, UTF-8 content type
Vec<u8> / BytesBinary response
Json<T>Serialized JSON with JSON content type
Html<T>HTML content type
StatusCodeEmpty response with that status
NoContent204 No Content
RedirectRedirect with a Location header
HeaderMap / [(K, V); N]Empty response carrying headers
(StatusCode, R)Overrides the status of response R
(headers, R)Adds headers without changing R’s body
(StatusCode, headers, R)Status + headers + response body
ResponseLow-level complete response
Result<T, E>Either branch becomes a response
Rustresponse patterns
use axum::{
    body::Body,
    http::{header, Response, StatusCode},
    response::{AppendHeaders, Html, IntoResponse, NoContent, Redirect},
};

async fn created() -> impl IntoResponse {
    (
        StatusCode::CREATED,
        [(header::LOCATION, "/notes/42")],
        "created",
    )
}

async fn deleted() -> NoContent {
    NoContent
}

async fn moved() -> Redirect {
    Redirect::temporary("/new-location")
}

async fn page() -> Html<&'static str> {
    Html("<h1>Notes</h1>")
}

async fn repeated_headers() -> impl IntoResponse {
    (
        AppendHeaders([
            ("set-cookie", "theme=dark; Path=/"),
            ("set-cookie", "density=compact; Path=/"),
        ]),
        "preferences saved",
    )
}

async fn low_level() -> Response<Body> {
    Response::builder()
        .status(StatusCode::ACCEPTED)
        .header("x-job-id", "job-42")
        .body(Body::from("queued"))
        .unwrap()
}
Status override trap

A tuple status can overwrite an inner response’s status. For example, (StatusCode::IM_A_TEAPOT, Json(value)) stays 418 even if JSON serialization fails internally. Use a custom response type when serialization errors must control the status.

08 / 31

Shared state#

State is data cloned into the application service and borrowed by handlers. A database pool is already designed to be cloned; other shared values commonly use Arc.

Rusttyped application state
use axum::{extract::State, routing::{get, post}, Json, Router};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;

#[derive(Clone)]
struct AppState {
    notes: Arc<RwLock<Vec<Note>>>,
}

#[derive(Clone, Deserialize, Serialize)]
struct Note {
    title: String,
}

async fn list(State(state): State<AppState>) -> Json<Vec<Note>> {
    let notes = state.notes.read().await;
    Json(notes.clone())
}

async fn create(
    State(state): State<AppState>,
    Json(note): Json<Note>,
) {
    state.notes.write().await.push(note);
}

let state = AppState {
    notes: Arc::new(RwLock::new(Vec::new())),
};

let app = Router::new()
    .route("/notes", get(list).post(create))
    .with_state(state);
The Router<S> detail

Router<AppState> means “this router is still missing an AppState,” not “this router contains AppState.” After .with_state(state), it normally becomes Router<()> and can be served.

Substates with FromRef

Rustextract one field from larger state
use axum::extract::{FromRef, State};

#[derive(Clone)]
struct AppState {
    config: Config,
    database: DatabasePool,
}

#[derive(Clone)]
struct Config {
    public_url: String,
}

impl FromRef<AppState> for Config {
    fn from_ref(state: &AppState) -> Self {
        state.config.clone()
    }
}

async fn public_url(State(config): State<Config>) -> String {
    config.public_url
}

With the macros feature, #[derive(FromRef)] can generate field conversions. Prefer State for compile-time safety. Use Extension for per-request data added by middleware or when dynamic extension storage is specifically useful.

09 / 31

Errors & rejections#

In a handler, Err is still a normal HTTP response when the error type implements IntoResponse. True Tower service errors must be converted before reaching Hyper.

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

enum AppError {
    NotFound,
    Validation(String),
    Internal,
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            Self::NotFound => (StatusCode::NOT_FOUND, "note not found".to_owned()),
            Self::Validation(message) => (StatusCode::UNPROCESSABLE_ENTITY, message),
            Self::Internal => {
                // Log the real internal error separately. Do not leak it.
                (StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_owned())
            }
        };

        (status, Json(json!({ "error": message }))).into_response()
    }
}

async fn show_note() -> Result<Json<Note>, AppError> {
    Err(AppError::NotFound)
}

Handle an extractor rejection inside a handler

Rustcustom JSON error
use axum::{
    extract::rejection::JsonRejection,
    http::StatusCode,
    Json,
};
use serde_json::{json, Value};

async fn create_note(
    payload: Result<Json<CreateNote>, JsonRejection>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    let Json(input) = payload.map_err(|rejection| {
        (
            rejection.status(),
            Json(json!({ "error": rejection.body_text() })),
        )
    })?;

    Ok(Json(json!({ "saved": input.title })))
}

Major built-in rejection families include JsonRejection, FormRejection, QueryRejection, PathRejection, BytesRejection, StringRejection, ExtensionRejection, RawFormRejection, RawPathParamsRejection, and MatchedPathRejection. They are non-exhaustive: keep a catch-all match arm.

Convert fallible Tower errors

Rusttimeout error layer
use axum::{
    error_handling::HandleErrorLayer,
    http::StatusCode,
    BoxError, Router,
};
use std::time::Duration;
use tower::ServiceBuilder;

let app = Router::new()
    // add routes first
    .layer(
        ServiceBuilder::new()
            .layer(HandleErrorLayer::new(|error: BoxError| async move {
                if error.is::<tower::timeout::error::Elapsed>() {
                    (StatusCode::REQUEST_TIMEOUT, "request timed out")
                } else {
                    (StatusCode::INTERNAL_SERVER_ERROR, "service failed")
                }
            }))
            .timeout(Duration::from_secs(10)),
    );