Extractors
18 min read
In Express.js you reach into a single req object for everything — req.params, req.query, req.body, req.headers. Axum flips this around: each piece of the request becomes a typed function parameter called an extractor, and the framework parses and validates it for you before your handler ever runs.
Quick Overview
Section titled “Quick Overview”An extractor is a type that knows how to build itself from an incoming HTTP request. Instead of pulling values out of one big req object and hoping they exist, you declare exactly what you need in your handler’s signature — Path<u64>, Query<Pagination>, Json<CreateUser> — and Axum populates them or returns a 400/422 automatically. This pushes request parsing and validation into the type system, so a handler that compiles has already been handed correctly-typed data.
The current stable toolchain is Rust 1.96.0 on the latest stable edition (2024); cargo new selects it automatically. This page targets axum 0.8.
TypeScript/JavaScript Example
Section titled “TypeScript/JavaScript Example”In Express, the request is a single object and you destructure whatever you need from it. Nothing is typed or validated at the framework level — that is your job.
// Express.js — everything hangs off `req`import express, { Request, Response } from "express";
const app = express();app.use(express.json());
interface Pagination { page: number; perPage: number;}
interface CreateUser { name: string; email: string;}
// Path param + query stringapp.get("/users/:id", (req: Request, res: Response) => { const id = Number(req.params.id); // string -> number by hand if (Number.isNaN(id)) { return res.status(400).json({ error: "id must be a number" }); }
const page = Number(req.query.page ?? "1"); const perPage = Number(req.query.perPage ?? "20"); const pagination: Pagination = { page, perPage };
res.json({ id, ...pagination });});
// JSON body + a headerapp.post("/users", (req: Request, res: Response) => { const body = req.body as CreateUser; // a lie: nothing was actually checked if (typeof body.name !== "string" || typeof body.email !== "string") { return res.status(400).json({ error: "name and email are required" }); }
const userAgent = req.get("user-agent") ?? "unknown"; res.status(201).json({ id: 1, ...body, userAgent });});
app.listen(3000);Key points:
- One
reqobject; you destructureparams,query,body, headers manually. req.params.idis always astring— you convert and validate yourself.req.body as CreateUseris a TypeScript cast, not a runtime check. The cast compiles even if the body is{}ornull.- Forgetting
app.use(express.json())silently leavesreq.bodyasundefined.
Rust Equivalent
Section titled “Rust Equivalent”In Axum each part of the request is a separate, typed parameter. Axum parses it, and if parsing fails the client gets a sensible error before your code runs.
use axum::{ extract::{Json, Path, Query}, http::{header::USER_AGENT, HeaderMap, StatusCode}, routing::get, Router,};use serde::{Deserialize, Serialize};
#[derive(Deserialize)]struct Pagination { page: Option<u32>, per_page: Option<u32>,}
#[derive(Deserialize, Serialize)]struct CreateUser { name: String, email: String,}
#[derive(Serialize)]struct UserResponse { id: u64, name: String, email: String, user_agent: String,}
// Path param + query string. `id` is already a `u64`.async fn get_user(Path(id): Path<u64>, Query(pg): Query<Pagination>) -> String { let page = pg.page.unwrap_or(1); let per_page = pg.per_page.unwrap_or(20); format!("user {id}, page {page}, per_page {per_page}")}
// JSON body + a header. `body` is guaranteed to have `name` and `email`.async fn create_user( headers: HeaderMap, Json(body): Json<CreateUser>,) -> (StatusCode, Json<UserResponse>) { let user_agent = headers .get(USER_AGENT) .and_then(|v| v.to_str().ok()) .unwrap_or("unknown") .to_string();
let response = UserResponse { id: 1, name: body.name, email: body.email, user_agent, }; (StatusCode::CREATED, Json(response))}
#[tokio::main]async fn main() { let app = Router::new() .route("/users/{id}", get(get_user)) .route("/users", get(get_user).post(create_user));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000") .await .unwrap(); axum::serve(listener, app).await.unwrap();}[dependencies]axum = "0.8"serde = { version = "1", features = ["derive"] }serde_json = "1"tokio = { version = "1", features = ["full"] }Key points:
Path<u64>gives you a realu64— the conversion and the “is it a number?” check happen for free.Query<Pagination>deserializes the query string into a struct viaserde.Json<CreateUser>deserializes and validates the shape of the body. Ifemailis missing, the request never reaches your code.HeaderMapis itself an extractor — noHeader<...>wrapper needed.
Detailed Explanation
Section titled “Detailed Explanation”What “extractor” actually means
Section titled “What “extractor” actually means”An extractor is any type that implements one of two traits:
FromRequestParts<S>— builds itself from the request metadata (method, URI, headers, extensions) without touching the body.Path,Query,HeaderMap, andStateare all of this kind. You can have many of these in one handler.FromRequest<S>— builds itself by consuming the entire request, body included.Json,Bytes,String, andFormare of this kind. Because the body can only be read once, at most one body extractor is allowed, and it must come last.
The S is your application’s shared state type (covered in state-management.md). For handlers without state it is inferred.
When a request arrives, Axum runs each extractor in declaration order. Each one returns a Result; on Err it short-circuits and turns the rejection into an HTTP response, and your handler is never called.
Path — one value, a tuple, or a struct
Section titled “Path — one value, a tuple, or a struct”Path is generic over how you want the captured segments shaped:
use axum::extract::Path;use serde::Deserialize;
// Single segment -> single value.async fn one(Path(id): Path<u64>) -> String { format!("user {id}")}
// Multiple segments -> a tuple, in route order.async fn two(Path((user_id, post_id)): Path<(u64, u64)>) -> String { format!("user {user_id} post {post_id}")}
// Multiple segments -> a struct, matched by NAME.#[derive(Deserialize)]struct PostPath { user_id: u64, post_id: u64,}
async fn named(Path(p): Path<PostPath>) -> String { format!("user {} post {}", p.user_id, p.post_id)}The routes would be /users/{id}, /users/{user_id}/posts/{post_id}, and so on. Note the {name} syntax — axum 0.8 replaced the old :name form. For the full routing story see routing.md.
Note: With a tuple, segments are matched by position. With a struct, they are matched by field name against the
{name}captures in the route. The struct form is more robust because reordering route segments will not silently swap your values.
Query — the query string as a struct
Section titled “Query — the query string as a struct”Query<T> percent-decodes the query string and deserializes it into T. Use Option<...> for parameters that may be absent:
use axum::extract::Query;use serde::Deserialize;use std::collections::HashMap;
#[derive(Deserialize)]struct Filters { status: Option<String>, limit: Option<u32>,}
async fn search(Query(f): Query<Filters>) -> String { format!("status={:?} limit={:?}", f.status, f.limit)}
// When you do not know the keys ahead of time:async fn raw(Query(params): Query<HashMap<String, String>>) -> String { format!("{params:?}")}Tip: Plain
Querydoes not handle repeated keys like?tag=a&tag=binto aVec. For that, addaxum-extraand useaxum_extra::extract::Query, which supportsVec<String>fields.
Json — the body, deserialized and checked
Section titled “Json — the body, deserialized and checked”Json<T> reads the whole body, requires a Content-Type: application/json header, and runs serde deserialization. A successful extraction means T’s required fields were all present and well-typed.
Json is also a response type: returning Json(value) serializes value and sets the content type. Response usage is covered in request-response.md and json-apis.md; here we focus on its extractor role.
Headers and State
Section titled “Headers and State”HeaderMap gives you the full header set. State<T> hands you a clone of shared application state (a database pool, config, an in-memory store). Both implement FromRequestParts, so they coexist freely with Path and Query:
use axum::extract::State;use axum::http::{header::USER_AGENT, HeaderMap};
#[derive(Clone)]struct AppState { app_name: String,}
async fn whoami(State(state): State<AppState>, headers: HeaderMap) -> String { let ua = headers .get(USER_AGENT) .and_then(|v| v.to_str().ok()) .unwrap_or("unknown"); format!("{}: {ua}", state.app_name)}State deserves its own treatment — see state-management.md.
What rejections look like over the wire
Section titled “What rejections look like over the wire”These are the real responses from the server above. Notice the status codes are chosen for you:
GET /users/42 -> 200 user 42, page 1, per_page 20GET /users/abc -> 400 Invalid URL: Cannot parse `abc` to a `u64`GET /users/42?page=3&per_page=50 -> 200 user 42, page 3, per_page 50GET /users/42?page=abc -> 400 Failed to deserialize query string: page: invalid digit found in stringPOST /users {"name":"Ada","email":"ada@x.io"} -> 201 {"id":1,"name":"Ada","email":"ada@x.io"}POST /users {"name":"Ada"} -> 422 Failed to deserialize the JSON body into the target type: missing field `email` at line 1 column 14POST /users (no Content-Type) -> 415 Expected request with `Content-Type: application/json`A bad path segment is a 400, a malformed query is a 400, a JSON body of the wrong shape is a 422 Unprocessable Entity, and a missing content type is a 415 Unsupported Media Type — all before your handler runs.
Key Differences
Section titled “Key Differences”| Concern | Express.js | Axum |
|---|---|---|
| Where data comes from | one req object | one typed parameter per piece |
| Param types | always string; convert by hand | parsed to the type you declare (u64, structs) |
| Body validation | manual, or a separate library | Json<T> checks shape via serde automatically |
| Missing/invalid input | you write the 400 | framework returns 400/422/415 |
| Body read | req.body after express.json() | one FromRequest extractor, always last |
| ”I forgot to parse the body” | req.body is undefined at runtime | the code does not compile / a 415 is returned |
The deeper idea: in Express, request parsing is imperative work inside the handler. In Axum it is declarative metadata in the signature. The handler body starts from valid, typed data, the same way a function with typed parameters starts from valid arguments.
FromRequestParts vs FromRequest
Section titled “FromRequestParts vs FromRequest”This distinction is the single most important thing to internalize:
FromRequestPartsextractors read only metadata and are cheap and composable — use as many as you like.FromRequestextractors consume the body — exactly one, and it must be the last parameter.
You can write your own extractor by implementing FromRequestParts. In axum 0.8 the trait uses native async fn, so no #[async_trait] is needed:
use axum::extract::FromRequestParts;use axum::http::{header::AUTHORIZATION, request::Parts, StatusCode};
// Pull a bearer token out of the Authorization header.struct ApiKey(String);
impl<S> FromRequestParts<S> for ApiKeywhere S: Send + Sync,{ type Rejection = (StatusCode, &'static str);
async fn from_request_parts( parts: &mut Parts, _state: &S, ) -> Result<Self, Self::Rejection> { let header = parts .headers .get(AUTHORIZATION) .and_then(|v| v.to_str().ok()) .ok_or((StatusCode::UNAUTHORIZED, "missing Authorization header"))?;
let token = header .strip_prefix("Bearer ") .ok_or((StatusCode::UNAUTHORIZED, "expected a Bearer token"))?;
Ok(ApiKey(token.to_string())) }}
// Now `ApiKey` is usable like any built-in extractor:async fn protected(ApiKey(token): ApiKey) -> String { format!("token starts with {}", &token[..token.len().min(4)])}This “extractor as a guard” pattern is the foundation of authentication.md and jwt.md.
Common Pitfalls
Section titled “Common Pitfalls”Putting a body extractor before another extractor
Section titled “Putting a body extractor before another extractor”A body extractor (Json, Bytes, String, Form) must be the last parameter. If it is not, the handler fails to satisfy the Handler trait and you get a wall of trait-bound errors:
use axum::{extract::{Json, Path}, routing::post, Router};use serde::Deserialize;
#[derive(Deserialize)]struct Body { name: String }
// does not compile (error[E0277]): Json is not the last parameterasync fn handler(Json(body): Json<Body>, Path(id): Path<u64>) -> String { format!("{} {id}", body.name)}
fn build() -> Router { Router::new().route("/items/{id}", post(handler))}The raw error is the cryptic the trait bound ... Handler<_, _> is not implemented. The fix-it nudge in that output is gold: add #[axum::debug_handler] to the handler (it needs the macros feature on axum). With it, the real message becomes precise:
error: `Json<_>` consumes the request body and thus must be the last argument to the handler function --> src/main.rs:8:30 |8 | async fn handler(Json(body): Json<Body>, Path(id): Path<u64>) -> String { | ^^^^The fix is simply to reorder: async fn handler(Path(id): Path<u64>, Json(body): Json<Body>).
Tip: Whenever a handler “won’t implement
Handler” and the error is unreadable, slap#[axum::debug_handler]on it. It exists purely to translate those trait errors into plain English.
Using the old :id route syntax
Section titled “Using the old :id route syntax”axum 0.7 used :id; axum 0.8 uses {id}. The old form is not a compile error — it panics at startup, at the line where you call .route("/users/:id", ...):
thread 'main' panicked at src/main.rs:9:37:Path segments must not start with `:`. For capture groups, use `{capture}`.If you meant to literally match a segment starting with a colon, call`without_v07_checks` on the router.Trusting req.body as T habits — Json<T> needs Deserialize
Section titled “Trusting req.body as T habits — Json<T> needs Deserialize”A subtle one: Json<T> as an extractor requires T: Deserialize. If your struct only derives Serialize (because you have only ever returned it), using it in a Json<T> parameter fails the Handler bound. Derive both Serialize and Deserialize for types that travel in and out:
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)] // both, for a round-trip typestruct User { id: u64, name: String,}Forgetting that Query/Json distinguish “missing” from “wrong type”
Section titled “Forgetting that Query/Json distinguish “missing” from “wrong type””A missing optional field is fine if you use Option<T>. But a present but wrong-typed value is a hard error: ?page=abc against a page: Option<u32> is a 400, not a None. If you want “ignore garbage and default”, parse it as a String and convert yourself.
Reaching for async-trait to write a custom extractor
Section titled “Reaching for async-trait to write a custom extractor”You do not need it. Native async fn in traits has been stable since Rust 1.75, and axum 0.8’s FromRequestParts/FromRequest use it directly. The async-trait crate is only relevant when you need dyn Trait dynamic dispatch, which extractors do not.
Best Practices
Section titled “Best Practices”- Declare exactly what you need. Prefer
Path<u64>overPath<String>so the framework rejects non-numeric ids for you. - Use structs for
Queryand multi-segmentPath. Named fields are self-documenting and robust to reordering. Reserve tuples for one or two obvious segments. - Make optional query params
Option<T>and apply defaults in the handler withunwrap_or. - Keep the body extractor last, always. Treat it as a rule, not a per-handler decision.
- Derive both
SerializeandDeserializeon DTOs that are accepted and returned. - Write a custom
FromRequestPartsextractor for cross-cutting concerns (auth, tenant resolution, request ids). A guard that lives in the signature cannot be forgotten the way a manual check inside the body can. - Override rejections when you want a uniform error body. Extract
Result<Json<T>, JsonRejection>to shape the4xxyourself, or centralize it as shown in error-handling-web.md. - Reach for
#[axum::debug_handler]during development when extractor errors are noisy.
Real-World Example
Section titled “Real-World Example”A small, authenticated user API that combines shared State, a custom AuthUser guard extractor, Path, Query, and a hand-shaped JSON rejection. Every line below is compile-verified against axum 0.8.
use axum::{ extract::{rejection::JsonRejection, FromRequestParts, Json, Path, Query, State}, http::{header::AUTHORIZATION, request::Parts, StatusCode}, response::{IntoResponse, Response}, routing::get, Router,};use serde::{Deserialize, Serialize};use std::collections::HashMap;use std::sync::{Arc, Mutex};
#[derive(Clone)]struct AppState { users: Arc<Mutex<HashMap<u64, User>>>, api_token: String,}
#[derive(Clone, Serialize, Deserialize)]struct User { id: u64, name: String,}
#[derive(Deserialize)]struct ListParams { name_contains: Option<String>,}
// Guard extractor: validates the bearer token against application state.struct AuthUser;
impl FromRequestParts<AppState> for AuthUser { type Rejection = (StatusCode, String);
async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result<Self, Self::Rejection> { let token = parts .headers .get(AUTHORIZATION) .and_then(|v| v.to_str().ok()) .and_then(|s| s.strip_prefix("Bearer ")) .ok_or((StatusCode::UNAUTHORIZED, "missing bearer token".to_string()))?;
if token != state.api_token { return Err((StatusCode::UNAUTHORIZED, "invalid token".to_string())); } Ok(AuthUser) }}
// The guard runs first; if it rejects, the rest never runs.async fn list_users( _auth: AuthUser, State(state): State<AppState>, Query(params): Query<ListParams>,) -> Json<Vec<User>> { let users = state.users.lock().unwrap(); let needle = params.name_contains.unwrap_or_default().to_lowercase(); let out: Vec<User> = users .values() .filter(|u| needle.is_empty() || u.name.to_lowercase().contains(&needle)) .cloned() .collect(); Json(out)}
async fn get_user( _auth: AuthUser, State(state): State<AppState>, Path(id): Path<u64>,) -> Result<Json<User>, StatusCode> { state .users .lock() .unwrap() .get(&id) .cloned() .map(Json) .ok_or(StatusCode::NOT_FOUND)}
#[derive(Serialize)]struct ApiError { error: String,}
// Take the Result form of the body extractor to shape our own 422 body.async fn create_user(payload: Result<Json<User>, JsonRejection>) -> Response { match payload { Ok(Json(user)) => (StatusCode::CREATED, Json(user)).into_response(), Err(rejection) => ( StatusCode::UNPROCESSABLE_ENTITY, Json(ApiError { error: rejection.body_text() }), ) .into_response(), }}
#[tokio::main]async fn main() { let mut seed = HashMap::new(); seed.insert(1, User { id: 1, name: "Ada".into() });
let state = AppState { users: Arc::new(Mutex::new(seed)), api_token: "secret".into(), };
let app = Router::new() .route("/users", get(list_users).post(create_user)) .route("/users/{id}", get(get_user)) .with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000") .await .unwrap(); axum::serve(listener, app).await.unwrap();}Exercising it with curl produces these real responses:
GET /users -> 401 missing bearer tokenGET /users -H 'Authorization: Bearer nope' -> 401 invalid tokenGET /users -H 'Authorization: Bearer secret' -> 200 [{"id":1,"name":"Ada"}]GET /users/1 -H 'Authorization: Bearer secret' -> 200 {"id":1,"name":"Ada"}GET /users/99 -H 'Authorization: Bearer secret' -> 404POST /users {"id":"oops"} -> 422 {"error":"Failed to deserialize the JSON body into the target type: id: invalid type: string \"oops\", expected u64 at line 1 column 12"}The guard, the state, and the parsing all happen declaratively; the handler bodies only ever see valid, authenticated, typed data.
Further Reading
Section titled “Further Reading”- axum
extractmodule docs — the canonical list of built-in extractors and the order rules. FromRequestPartsandFromRequesttrait reference.axum::debug_handler— turning opaqueHandlererrors into readable messages.- serde derive docs — how
DeserializeshapesQuery/Jsonparsing.
Within this guide:
- routing.md —
{id}path syntax, method routing, nested routers (where path captures come from). - state-management.md — the
State<T>extractor,Arc,FromRef. - request-response.md —
Jsonand tuples as responses,IntoResponse, status codes. - json-apis.md — a full CRUD resource built on these extractors.
- validation.md — going beyond shape checks to business-rule validation.
- error-handling-web.md — a centralized
AppErrorand uniform rejection bodies. - authentication.md and jwt.md — the guard-extractor pattern in production form.
- Foundations: async/await, generics and traits, error handling, and the language basics / getting started.
- Persisting the data these handlers receive: Database.
Exercises
Section titled “Exercises”Exercise 1: Typed path and optional query
Section titled “Exercise 1: Typed path and optional query”Difficulty: Easy
Objective: Build a handler that extracts a numeric product id from the path and an optional currency query parameter.
Instructions:
- Add a route
/products/{id}. - Write a handler that takes
Path<u64>and aQueryof a struct with anOption<String>field namedcurrency. - Return a string like
product 7 priced in USD, defaulting the currency to"USD"when absent.
Solution
use axum::{extract::{Path, Query}, routing::get, Router};use serde::Deserialize;
#[derive(Deserialize)]struct PriceQuery { currency: Option<String>,}
async fn show_product(Path(id): Path<u64>, Query(q): Query<PriceQuery>) -> String { let currency = q.currency.unwrap_or_else(|| "USD".to_string()); format!("product {id} priced in {currency}")}
#[tokio::main]async fn main() { let app = Router::new().route("/products/{id}", get(show_product)); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000") .await .unwrap(); axum::serve(listener, app).await.unwrap();}GET /products/7 returns product 7 priced in USD; GET /products/7?currency=EUR returns product 7 priced in EUR; GET /products/abc is rejected with a 400.
Exercise 2: Fix the ordering bug
Section titled “Exercise 2: Fix the ordering bug”Difficulty: Medium
Objective: Repair a handler that fails to compile because its body extractor is in the wrong position.
Instructions:
The following handler does not compile. Identify why (add #[axum::debug_handler] if the error is unclear), then fix it so the route works.
use axum::{extract::{Json, Path}, routing::put, Router};use serde::Deserialize;
#[derive(Deserialize)]struct Update { name: String,}
// does not compileasync fn rename(Json(body): Json<Update>, Path(id): Path<u64>) -> String { format!("renamed {id} to {}", body.name)}
fn app() -> Router { Router::new().route("/users/{id}", put(rename))}Solution
Json consumes the request body and must be the last parameter. Move Path (a FromRequestParts extractor) ahead of it:
use axum::{extract::{Json, Path}, routing::put, Router};use serde::Deserialize;
#[derive(Deserialize)]struct Update { name: String,}
async fn rename(Path(id): Path<u64>, Json(body): Json<Update>) -> String { format!("renamed {id} to {}", body.name)}
fn app() -> Router { Router::new().route("/users/{id}", put(rename))}
#[tokio::main]async fn main() { let listener = tokio::net::TcpListener::bind("0.0.0.0:3000") .await .unwrap(); axum::serve(listener, app()).await.unwrap();}PUT /users/3 with body {"name":"Bob"} returns renamed 3 to Bob.
Exercise 3: A custom guard extractor
Section titled “Exercise 3: A custom guard extractor”Difficulty: Hard
Objective: Implement a FromRequestParts extractor that requires an X-Request-Id header and exposes it to handlers.
Instructions:
- Define a
RequestId(String)newtype. - Implement
FromRequestParts<S>for it (generic over any stateS: Send + Sync). - If the
x-request-idheader is missing, reject with400 Bad Requestand a message. - Use it in a handler alongside
Path<u64>and confirm the ordering rules (metadata extractors can appear in any order among themselves).
Solution
use axum::{ extract::{FromRequestParts, Path}, http::{request::Parts, HeaderName, StatusCode}, routing::get, Router,};
struct RequestId(String);
impl<S> FromRequestParts<S> for RequestIdwhere S: Send + Sync,{ type Rejection = (StatusCode, &'static str);
async fn from_request_parts( parts: &mut Parts, _state: &S, ) -> Result<Self, Self::Rejection> { let name = HeaderName::from_static("x-request-id"); let value = parts .headers .get(&name) .and_then(|v| v.to_str().ok()) .ok_or((StatusCode::BAD_REQUEST, "missing X-Request-Id header"))?; Ok(RequestId(value.to_string())) }}
async fn handler(RequestId(req_id): RequestId, Path(id): Path<u64>) -> String { format!("request {req_id} -> resource {id}")}
#[tokio::main]async fn main() { let app = Router::new().route("/items/{id}", get(handler)); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000") .await .unwrap(); axum::serve(listener, app).await.unwrap();}GET /items/9 without the header returns 400 missing X-Request-Id header; with -H 'X-Request-Id: abc123' it returns request abc123 -> resource 9. Because both RequestId and Path are FromRequestParts, their order relative to each other does not matter.