Extractors

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 serde 1.0, checked on 2026-10-06. An extractor is a type that knows how to build itself from (part of) a request. A handler lists the extractors it needs as arguments, and axum runs them before the handler body. If one fails, its rejection becomes the response and the handler never runs. Writing your own extractors is covered on Custom Extractors, and the extras in axum-extra on axum-extra.

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), extractors run after routing and middleware, immediately before the handler: first every FromRequestParts extractor (headers, path, query, state), then at most one FromRequest body extractor. The first rejection short-circuits to a response, so the handler never runs and the response goes back out through the middleware like any other.

The built-in extractors

Extractor Kind What it gives you

Path<T>

parts

path captures, deserialised into T (a scalar, tuple, struct or map)

Query<T>

parts

the query string deserialised into T (feature query)

RawQuery

parts

the raw query string as Option<String>

State<S>

parts

application state supplied with Router::with_state

Extension<T>

parts

a value from the request’s extensions, inserted by middleware or Extension layer

HeaderMap

parts

all request headers

Method

parts

the HTTP method

Uri

parts

the request URI (with the nest prefix stripped)

OriginalUri

parts

the URI before any nest (feature original-uri)

MatchedPath

parts

the route template that matched, such as /books/{isbn} (feature matched-path)

NestedPath

parts

the prefix the current router was nested at

ConnectInfo<T>

parts

connection data such as the peer SocketAddr; requires into_make_service_with_connect_info

http::request::Parts

parts

the whole request head (method, URI, version, headers, extensions)

Json<T>

body

a JSON body deserialised into T (feature json)

Form<T>

body

a URL-encoded form body (or the query string for GET/HEAD) (feature form)

Bytes, String

body

the raw body (the String must be valid UTF-8)

Multipart

body

a multipart/form-data body (feature multipart)

WebSocketUpgrade

parts

an upgradable WebSocket handshake (feature ws)

Request

body

the entire request, for full control

"Parts" extractors implement FromRequestParts<S> and only read the request head; "body" extractors implement FromRequest<S> and may consume the body.

use std::net::SocketAddr;

use axum::{
    Json, Router,
    extract::{ConnectInfo, MatchedPath, Path, Query, RawQuery},
    http::{HeaderMap, Method, Uri, header},
    routing::post,
};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct NewReview {
    rating: u8,
    text: String,
}

#[derive(Deserialize)]
struct Notify {
    #[serde(default)]
    notify: bool,
}

#[derive(Serialize)]
struct Echo {
    method: String,
    uri: String,
    route: String,
    isbn: String,
    raw_query: Option<String>,
    notify: bool,
    user_agent: Option<String>,
    peer: String,
    rating: u8,
    text: String,
}

#[allow(clippy::too_many_arguments)] // eight extractors, deliberately
async fn add_review(
    method: Method,
    uri: Uri,
    matched: MatchedPath,
    ConnectInfo(peer): ConnectInfo<SocketAddr>,
    headers: HeaderMap,
    Path(isbn): Path<String>,
    RawQuery(raw_query): RawQuery,
    Query(Notify { notify }): Query<Notify>,
    Json(review): Json<NewReview>, // body extractor: last
) -> Json<Echo> {
    let user_agent = headers.get(header::USER_AGENT).and_then(|v| v.to_str().ok()).map(str::to_owned);
    Json(Echo {
        method: method.to_string(),
        uri: uri.to_string(),
        route: matched.as_str().to_owned(),
        isbn,
        raw_query,
        notify,
        user_agent,
        peer: peer.to_string(),
        rating: review.rating,
        text: review.text,
    })
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/api/v1/books/{isbn}/reviews", post(add_review));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    // ConnectInfo needs the connection's address, so serve with connect info:
    axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await.unwrap();
}
$ curl -s -H 'content-type: application/json' -d '{"rating":5,"text":"A classic."}' \
    'localhost:3000/api/v1/books/9780441013593/reviews?notify=true' | jq
{
  "method": "POST",
  "uri": "/api/v1/books/9780441013593/reviews?notify=true",
  "route": "/api/v1/books/{isbn}/reviews",
  "isbn": "9780441013593",
  "raw_query": "notify=true",
  "notify": true,
  "user_agent": "curl/8.14.1",
  "peer": "127.0.0.1:52144",
  "rating": 5,
  "text": "A classic."
}

Behind a reverse proxy, ConnectInfo reports the proxy’s address; read the client address from a trusted X-Forwarded-For/Forwarded header instead (see Rate Limiting & Load Shedding).

The order of extractors

The request body is a stream that can be read only once. axum therefore enforces, at compile time, that:

  1. any number of FromRequestParts extractors come first, in any order;

  2. at most one FromRequest (body-consuming) extractor appears, and it is the last argument.

flowchart LR REQ(["request"]) --> P1["parts extractor 1
e.g. State"] P1 -->|ok| P2["parts extractor 2
e.g. Path"] P2 -->|ok| PN["... more parts extractors
Query, HeaderMap ..."] PN -->|ok| B["body extractor (last)
Json / Form / Bytes"] B -->|ok| H["handler body runs"] H --> RESP(["IntoResponse"]) P1 -->|rejection| R(["rejection response"]) P2 -->|rejection| R PN -->|rejection| R B -->|rejection| R

Extractors run in argument order and the first rejection short-circuits: put cheap checks that reject most bad requests (authentication) before expensive ones. Putting Json anywhere but last does not compile; with #[debug_handler] the error says exactly that:

use axum::{Json, Router, extract::Path, routing::post};

#[axum::debug_handler]
async fn add_review(Json(text): Json<String>, Path(isbn): Path<String>) -> String {
    format!("{isbn}: {text}")
}

fn main() {
    let _app: Router = Router::new().route("/books/{isbn}/reviews", post(add_review));
    // error: `Json<_>` consumes the request body and thus must be the last argument to the handler function
    // error[E0277]: the trait bound `fn(Json<String>, Path<String>) -> ... {add_review}: Handler<_, _>` is not satisfied
}

Two body extractors (Json and String, say) can never both run. If you need the raw bytes and the parsed value — to verify a webhook signature, for example — extract Bytes and parse it yourself with serde_json::from_slice.

Rejections

Every extractor has an associated Rejection type that implements IntoResponse. The defaults are plain-text responses with these status codes (verified against axum 0.8.9):

Situation Status Default body

Json: missing or wrong Content-Type

415

Expected request with `Content-Type: application/json`

Json: not valid JSON, or trailing characters after the document (since 0.8.5)

400

Failed to parse the request body as JSON: ...

Json: valid JSON, wrong shape (missing field, wrong type)

422

Failed to deserialize the JSON body into the target type: year: invalid type ...

Form: wrong Content-Type

415

Form requests must have `Content-Type: application/x-www-form-urlencoded`

Form: field cannot be deserialised

422

Failed to deserialize form body: year: invalid digit found in string

Query: field missing or invalid

400

Failed to deserialize query string: page: invalid digit found in string

Path: capture cannot be parsed

400

Invalid URL: Cannot parse `abc` to a `u32`

Path: wrong number of parameters for a tuple (a programming error)

500

Wrong number of path arguments ...

body larger than the limit (2 MB by default)

413

Failed to buffer the request body: length limit exceeded

State missing

 — 

compile-time error, never a runtime rejection

Extension<T> missing

500

Missing request extension: Extension of type ... was not found

Since 0.8, Query and Form errors name the field that failed (page: invalid digit ...), thanks to serde_path_to_error. All rejections are logged by axum at TRACE level under the target axum::rejection, so RUST_LOG=axum::rejection=trace shows why requests are being turned away.

For a JSON API you want rejections as JSON too. The cleanest ways — a wrapper extractor with #[derive(FromRequest)] and #[from_request(via(axum::Json), rejection(AppError))], or axum-extra’s WithRejection — are on Custom Extractors. For one-off cases, extract Result<T, T::Rejection>.

Option<T> and Result<T, T::Rejection>

Wrapping an extractor changes what happens when it fails:

  • Result<Json<T>, JsonRejection> never rejects: the handler receives the error and decides. Every extractor supports this form.

  • Option<T> is supported only by extractors that implement OptionalFromRequestParts or OptionalFromRequest (new in axum 0.8). Each defines what "absent" means and still rejects invalid input: Option<Path<u32>> is None on a route with no captures but rejects /op/abc with 400; Option<Json<T>> is None when there is no JSON content type but rejects malformed JSON; axum-extra’s Option<TypedHeader<T>> is None when the header is missing but rejects an unparseable one.

  • Option<Query<T>> is not implemented in 0.8 (it was removed before 0.8.0 because it silently hid errors). Make the fields optional instead — Query<Filters> with Option<...> fields — or use axum-extra’s OptionalQuery.

use axum::{
    Json, Router,
    extract::{Path, rejection::JsonRejection},
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::{get, post},
};
use serde::Deserialize;
use serde_json::json;

#[derive(Deserialize)]
struct NewBook {
    title: String,
    year: u16,
}

// Result: turn the rejection into a JSON error ourselves.
async fn create_book(payload: Result<Json<NewBook>, JsonRejection>) -> Response {
    match payload {
        Ok(Json(book)) => (StatusCode::CREATED, format!("created {} ({})\n", book.title, book.year)).into_response(),
        Err(rejection) => (
            rejection.status(),
            Json(json!({ "title": "Invalid request body", "status": rejection.status().as_u16(), "detail": rejection.body_text() })),
        )
            .into_response(),
    }
}

// Option: one handler for /shelves and /shelves/{n}.
async fn shelf(number: Option<Path<u32>>) -> String {
    match number {
        Some(Path(n)) => format!("shelf {n}\n"),
        None => "all shelves\n".to_owned(),
    }
}

fn app() -> Router {
    Router::new()
        .route("/api/v1/books", post(create_book))
        .route("/shelves", get(shelf))
        .route("/shelves/{n}", get(shelf))
}

#[tokio::main]
async fn main() {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app()).await.unwrap();
}
$ curl -s -H 'content-type: application/json' -d '{"title":"Dune"}' localhost:3000/api/v1/books
{"detail":"Failed to deserialize the JSON body into the target type: missing field `year` at line 1 column 16","status":422,"title":"Invalid request body"}
$ curl -s localhost:3000/shelves/3
shelf 3

Path in detail

Path<T> deserialises the captures with serde, so the target shape determines how they are matched:

use std::collections::HashMap;

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

// one capture: a scalar
async fn by_isbn(Path(isbn): Path<String>) -> String {
    format!("isbn {isbn}\n")
}

// several captures: a tuple, in path order
async fn review_by_index(Path((isbn, index)): Path<(String, u32)>) -> String {
    format!("review #{index} of {isbn}\n")
}

// several captures: a struct, by name
#[derive(Deserialize)]
struct LoanPath {
    member_id: u64,
    loan_id: u64,
}

async fn member_loan(Path(p): Path<LoanPath>) -> String {
    format!("loan {} of member {}\n", p.loan_id, p.member_id)
}

// any captures: a map (useful in middleware and generic handlers)
async fn any_params(Path(params): Path<HashMap<String, String>>) -> String {
    let mut keys: Vec<_> = params.into_iter().map(|(k, v)| format!("{k}={v}")).collect();
    keys.sort();
    format!("{}\n", keys.join(", "))
}

fn app() -> Router {
    Router::new()
        .route("/books/{isbn}", get(by_isbn))
        .route("/books/{isbn}/reviews/{index}", get(review_by_index))
        .route("/members/{member_id}/loans/{loan_id}", get(member_loan))
        .route("/debug/{a}/{b}", get(any_params))
}

#[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: Path

  • Since 0.8, a tuple must have exactly as many elements as there are captures; a mismatch is a 500 (it is a bug in the route definition, not in the request).

  • Struct field names must equal the capture names. Use #[serde(rename = "...")] if they differ.

  • Captures are percent-decoded before deserialisation. Use RawPathParams to see the raw values.

  • A validated domain type can be the target directly: Path<Isbn> works when Isbn implements Deserialize with validation (shelf-core’s does, through #[serde(try_from = "String")]), and an invalid ISBN becomes a 400.

Yoon (Beginning Axum, chapter 2) shows the tuple and struct forms; the map form and the exact-arity rule are from the axum documentation.

Empty query values

A query string such as ?author=&page=2 is common from HTML forms whose inputs were left empty. serde sees author="", so Option<String> becomes Some("") and Option<u32> fails to parse. The official query-params-with-empty-strings example solves this with a small deserialiser helper:

use std::{fmt, str::FromStr};

use axum::{Router, extract::Query, routing::get};
use serde::{Deserialize, Deserializer, de};

#[derive(Debug, Deserialize)]
struct Filters {
    #[serde(default, deserialize_with = "empty_string_as_none")]
    author: Option<String>,
    #[serde(default, deserialize_with = "empty_string_as_none")]
    year: Option<u16>,
}

fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
where
    D: Deserializer<'de>,
    T: FromStr,
    T::Err: fmt::Display,
{
    let opt = Option::<String>::deserialize(de)?;
    match opt.as_deref() {
        None | Some("") => Ok(None),
        Some(s) => s.parse().map(Some).map_err(de::Error::custom),
    }
}

async fn search(Query(filters): Query<Filters>) -> String {
    format!("{filters:?}\n")
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/api/v1/books", get(search));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
$ curl -s 'localhost:3000/api/v1/books?author=&year=1965'
Filters { author: None, year: Some(1965) }
$ curl -s 'localhost:3000/api/v1/books'
Filters { author: None, year: None }

Query uses serde_urlencoded, which does not support repeated keys (?tag=a&tag=b) or nested structures. For those, use axum-extra’s Query (backed by serde_html_form) — see axum-extra.

Where the books are outdated

Crossley et al. (Rust for Scientific Computing, ch. 15) and older tutorials extract optional parameters with Option<Query<T>> and Option<T> for any extractor; in axum 0.8 Option is only available for extractors implementing OptionalFromRequestParts / OptionalFromRequest, and invalid input is rejected rather than silently turned into None. JSON bodies with trailing characters after the document were accepted until 0.8.5 and are now rejected with 400.