Skip to content
rusthacks.dev
Web

Axum Extractors Explained and the Order That Matters

Handler arguments in Axum are extractors that run in order, and only the last one may consume the request body. Learn the rule and the error it prevents.

8 min read
Summarize in AI
Flat illustration for the post “Axum Extractors Explained and the Order That Matters”, composed around web.

Axum handlers read like ordinary functions: the arguments are the things you want from the request, and the framework produces them. That is the whole pitch, and it holds until the day you reorder two arguments and the project stops compiling with an error about a trait that does not mention what you changed. The rule behind it is short and mechanical, and once it is clear, the error messages stop being mysterious. This post shows the two families of extractors, reproduces the ordering error with the compiler's own output, covers optional and fallible extraction, and explains how to write your own. Verified with axum 0.8.9 on Rust 1.99.

Two kinds of extractor

Axum splits extraction into two traits. FromRequestParts is implemented by extractors that read only the parts of a request: the method, URI, headers and extensions, never the body. FromRequest is implemented by extractors that take the whole request, body included. The distinction matters because a request body is a stream that can be read once. After one extractor consumes it, nothing is left for another.

ExtractorReadsTraitBody?
Path<T>captured URL segmentsFromRequestPartsno
Query<T>the query stringFromRequestPartsno
State<S>shared application stateFromRequestPartsno
HeaderMap, TypedHeaderrequest headersFromRequestPartsno
Extension<T>values added by middlewareFromRequestPartsno
Json<T>the body as JSONFromRequestyes
Form<T>the body as urlencoded formFromRequestyes
Bytes, Stringthe raw bodyFromRequestyes
Multiparta multipart bodyFromRequestyes
RequesteverythingFromRequestyes

The rule that follows: the last argument may be any extractor; every earlier argument must read parts only. A handler may have ten parts-based extractors and one body extractor at the end. Extractors run in argument order, left to right, and the first one that fails short-circuits the rest, returning its rejection as the response.

The error you get when you reorder

A handler that records a note on a thread takes a JSON body, a path id and an optional page in the query string. Written with the body first:

src/main.rsrust
use axum::{
    Json, Router,
    debug_handler,
    extract::{Path, Query},
    routing::post,
};
use serde::Deserialize;

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

#[derive(Deserialize)]
struct NewNote {
    text: String,
}

#[debug_handler]
async fn add_note(
    Json(body): Json<NewNote>,
    Path(id): Path<u32>,
    Query(p): Query<Pagination>,
) -> String {
    format!("note on {id} page {:?}: {}", p.page, body.text)
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/threads/{id}/notes", post(add_note));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
Text
error: `Json<_>` consumes the request body and thus must be the last argument to the handler function
  --> src/main.rs:20:31
   |
20 | async fn add_note(Json(body): Json<NewNote>, Path(id): Path<u32>, Query(p): Query<Pagination>) -> String {
   |                               ^^^^

error[E0277]: the trait bound `fn(Json<NewNote>, Path<u32>, Query<_>) -> _ {add_note}: Handler<_, _>` is not satisfied
  --> src/main.rs:26:63
   |
26 |     let app = Router::new().route("/threads/{id}/notes", post(add_note));
   |                                                          ---- ^^^^^^^^ the trait `Handler<_, _>` is not implemented for fn item ...
   |
   = note: Consider using `#[axum::debug_handler]` to improve the error message

This is the output with #[debug_handler] on the function. The first error is the one to read: it names the offending extractor and states the rule in one sentence. The second, which is all you would see without the attribute, is the generic "the trait Handler is not satisfied", the same message you get for a dozen unrelated mistakes. This is why adding the attribute is the first thing to try on any handler that will not register. The attribute needs axum = { version = "0.8", features = ["macros"] } and has no cost in release builds when you leave it on, because it only generates checks. The fix is to move the body extractor to the end:

src/main.rsrust
#[debug_handler]
async fn add_note(
    Path(id): Path<u32>,
    Query(p): Query<Pagination>,
    Json(body): Json<NewNote>,
) -> String {
    format!("note on {id} page {:?}: {}", p.page, body.text)
}
Shell
curl -s -X POST 'localhost:3000/threads/7/notes?page=2' \
  -H 'content-type: application/json' -d '{"text":"remember the milk"}'
Text
note on 7 page Some(2): remember the milk

Missing and malformed input

When an extractor fails, it returns a rejection, and Axum turns that into a response without running your handler. The defaults are sensible HTTP errors: a bad path segment is 400, a missing JSON content type is 415, a body that does not parse is 422 or 400 depending on whether it was malformed or wrong-shaped. Try the failing request against the handler above:

Shell
curl -s -i -X POST 'localhost:3000/threads/seven/notes' \
  -H 'content-type: application/json' -d '{"text":"x"}'
Text
HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8

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

The default bodies are plain text and name the problem, which is useful to a developer and wrong for an API whose clients expect JSON errors. Two tools change that. Wrapping the extractor in Result hands you the rejection to convert yourself. Wrapping it in Option turns the absence of an input into None, though only for extractors that opt in:

src/main.rsrust
use axum::extract::rejection::JsonRejection;

async fn lenient(
    Query(p): Query<Pagination>,
    body: Result<Json<NewNote>, JsonRejection>,
) -> String {
    match body {
        Ok(Json(n)) => format!("ok {} page {:?}", n.text, p.page),
        Err(rejection) => format!("rejected: {}", rejection.body_text()),
    }
}

async fn optional_body(body: Option<Json<NewNote>>) -> String {
    match body {
        Some(Json(n)) => format!("got {}", n.text),
        None => "no body".to_string(),
    }
}
Shell
curl -s -X POST 'localhost:3000/lenient?page=1' -H 'content-type: application/json' -d '{"txt":"x"}'
curl -s -X POST localhost:3000/opt
curl -s -X POST localhost:3000/opt -H 'content-type: application/json' -d '{"nope":1}'
Text
rejected: Failed to deserialize the JSON body into the target type: missing field `text` at line 1 column 11
no body
Failed to deserialize the JSON body into the target type: missing field `text` at line 1 column 10

The third request shows the subtlety. Option<Json<T>> in Axum 0.8 returns None only when the request has no JSON content type; a body that is present but wrong still produces the rejection, because the extractor implements the optional trait to distinguish "absent" from "invalid". That is a change from 0.7, where Option swallowed every failure and hid real bugs. Not every extractor implements the optional form: Option<Query<T>> does not compile in 0.8, so make query fields optional inside the struct (page: Option<u32>, as above) instead. Use the Result form when you want to map rejections into your own error type, the technique covered in error handling with IntoResponse.

Writing your own extractor

When several handlers read the same thing, make it an extractor. A bearer token pulled from the header is a parts-only extractor, so it implements FromRequestParts and can sit anywhere in the argument list:

src/main.rsrust
use axum::{extract::FromRequestParts, http::{StatusCode, request::Parts}};

struct BearerToken(String);

impl<S: Send + Sync> FromRequestParts<S> for BearerToken {
    type Rejection = (StatusCode, &'static str);

    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        let value = parts
            .headers
            .get("authorization")
            .and_then(|v| v.to_str().ok())
            .and_then(|v| v.strip_prefix("Bearer "))
            .ok_or((StatusCode::UNAUTHORIZED, "missing bearer token"))?;
        Ok(BearerToken(value.to_string()))
    }
}

async fn whoami(BearerToken(token): BearerToken) -> String {
    format!("token has {} bytes", token.len())
}

In Axum 0.8 the trait uses native async functions in traits, so there is no #[async_trait] attribute as there was before. Resist doing authentication in an extractor alone; the trade-off between extractors and middleware for access control is covered in middleware with Tower layers, and the ordering of cheap checks before expensive ones is the practical reason to keep an auth extractor first.

Extractors that need state

An extractor can read shared application state through the S type parameter, which is how State<AppState> itself works. Pattern-match a field out of the state with State(db): State<Pool> when Pool is derivable from the application state through FromRef. The mechanics are in sharing state between handlers. Ordering still follows the same rule: state extractors read parts, so they may go anywhere.

Extractors that need the rest of the request

Two extractors let you stop pretending a handler only needs the parsed pieces. Request gives you the whole request, body included, as the one allowed body consumer, and OriginalUri restores the full path when the router nested the handler under a prefix and stripped it:

src/main.rsrust
use axum::{extract::{OriginalUri, Request}, routing::get};

async fn debug_request(OriginalUri(uri): OriginalUri, req: Request) -> String {
    format!("{} {} via {} headers", req.method(), uri, req.headers().len())
}

let api = Router::new().route("/debug", get(debug_request));
let app = Router::new().nest("/api", api);
Shell
curl -s localhost:3000/api/debug
Text
GET /api/debug via 3 headers

Inside the nested router the request's own URI is just /debug, and OriginalUri keeps the /api prefix, which matters for logging, for building absolute links and for audit trails. The three headers are the ones curl sends (host, user-agent, accept). Use Request sparingly: a handler that takes it declares that it needs everything, which is rarely true, and it gives up the compile-time documentation that specific extractors provide.

When the rule does not apply

If you need the raw body and parsed headers in the same handler, take Request as the one body-consuming argument and read headers from it, or use middleware to place a parsed value in an extension before the handler runs. And if a handler needs to read the body twice (verify a signature over the raw bytes, then deserialize), extract Bytes, check, and call serde_json::from_slice yourself rather than stacking two extractors. For the overall layout of a service that uses these patterns, see an Axum production service and JSON requests and responses.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.