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 |
|---|---|---|
|
parts |
path captures, deserialised into |
|
parts |
the query string deserialised into |
|
parts |
the raw query string as |
|
parts |
application state supplied with |
|
parts |
a value from the request’s extensions, inserted by middleware or |
|
parts |
all request headers |
|
parts |
the HTTP method |
|
parts |
the request URI (with the |
|
parts |
the URI before any |
|
parts |
the route template that matched, such as |
|
parts |
the prefix the current router was nested at |
|
parts |
connection data such as the peer |
|
parts |
the whole request head (method, URI, version, headers, extensions) |
|
body |
a JSON body deserialised into |
|
body |
a URL-encoded form body (or the query string for |
|
body |
the raw body (the |
|
body |
a |
|
parts |
an upgradable WebSocket handshake (feature |
|
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.
Source: axum: Common extractors
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();
}
Source: axum: ConnectInfo
$ 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."
}
Source: axum: ConnectInfo
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:
-
any number of
FromRequestPartsextractors come first, in any order; -
at most one
FromRequest(body-consuming) extractor appears, and it is the last argument.
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
}
Source: axum: The order of extractors
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 |
|---|---|---|
|
415 |
|
|
400 |
|
|
422 |
|
|
415 |
|
|
422 |
|
|
400 |
|
|
400 |
|
|
500 |
|
body larger than the limit (2 MB by default) |
413 |
|
|
— |
compile-time error, never a runtime rejection |
|
500 |
|
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 implementOptionalFromRequestPartsorOptionalFromRequest(new in axum 0.8). Each defines what "absent" means and still rejects invalid input:Option<Path<u32>>isNoneon a route with no captures but rejects/op/abcwith400;Option<Json<T>>isNonewhen there is no JSON content type but rejects malformed JSON; axum-extra’sOption<TypedHeader<T>>isNonewhen 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>withOption<...>fields — or use axum-extra’sOptionalQuery.
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();
}
Source: axum: Optional extractors
$ 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
Source: axum: Optional extractors
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
RawPathParamsto see the raw values. -
A validated domain type can be the target directly:
Path<Isbn>works whenIsbnimplementsDeserializewith validation (shelf-core’s does, through#[serde(try_from = "String")]), and an invalid ISBN becomes a400.
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.