Handlers & IntoResponse
In Phase 2 you pulled pieces out of the URL with Path and Query. Now we close the loop. A handler
isn't a special kind of function with a magic signature you have to memorize — it's an ordinary async fn
that obeys one rule on each side.
📝 The mental model: the arguments extract FROM the request; the return value becomes the response.
Every parameter is a type that knows how to read part of the incoming request. The return type is a type
that knows how to turn itself into an outgoing HTTP response. That pairing — extractors in, IntoResponse
out — is the entire heart of axum. Once it clicks, you stop guessing at signatures and start deriving
them: "I need the JSON body, so I take Json<T>; I want to send back a created book with a 201, so I
return (StatusCode, Json<Book>)." The framework wires the rest.
We'll keep growing the books API. The types from earlier:
use ;
What just happened: Book derives Serialize because it travels out (we turn it into JSON for the
response). NewBook derives Deserialize because it comes in (we build it from the request body). The
direction of travel decides the derive — that distinction will matter in every handler below.
Json as an input
To accept a JSON request body, take Json<T> as a parameter, where T derives Deserialize. axum reads
the body, parses it as JSON, and hands you the deserialized value.
use Json;
async
What just happened: Json(payload): Json<NewBook> destructures the extractor right in the parameter
list, so inside the function payload is a plain NewBook — no unwrapping. If the body is missing or
isn't valid JSON for NewBook, axum rejects the request with a 400 Bad Request before your code ever
runs. You write the happy path; the extractor guards the door.
⚠️ Json<T> as an extractor must be the last parameter. Reading the body consumes the request, so
it has to come after extractors like Path and Query that only peek at the headers and URL. This is the
right order:
use Path;
use Json;
async
What just happened: Path(id) comes first (it reads from the URL), Json(payload) comes last (it
consumes the body). Put Json before Path and you get a compile error, because axum only lets the final
argument be a body-consuming extractor. When in doubt: body last.
Json as an output, and IntoResponse
The same Json type works in reverse. Return Json(value) where value's type derives Serialize, and
axum serializes it and sets Content-Type: application/json for you.
use Json;
async
What just happened: the return type is Json<Book>. axum sees that, serializes book to a JSON body,
and adds the JSON content-type header. You never touched the response object directly — you returned a
value that knows how to become a response.
That "knows how to become a response" is a real trait: IntoResponse. A handler's return type has to
implement it, and many common types already do, so you rarely write one yourself:
&strandString— a200 OKwith a plain-text body.Json<T>— a JSON body (whenT: Serialize).StatusCode— an empty response with just that status (e.g.StatusCode::NO_CONTENT).(StatusCode, T)— set the status and a body, whereTis itselfIntoResponse.(StatusCode, HeaderMap, T)— status, custom headers, and a body.Html<_>— an HTML body with the right content-type.Result<T, E>— succeed withTor fail withE, when both implementIntoResponse.
The tuple forms are the workhorses. Here's the canonical "create" handler that returns a 201 Created
along with the new book as JSON:
use StatusCode;
use Json;
async
What just happened: the return type (StatusCode, Json<Book>) is a tuple, and axum implements
IntoResponse for it: the first element becomes the status line (201 Created), the second becomes the
body (JSON). StatusCode lives in axum::http::StatusCode. This single pattern — Json in, status +
Json out — covers most write endpoints you'll ever build.
When a handler can fail: returning a Result
Real handlers fail. A lookup misses, the input is valid JSON but semantically wrong. Because Result<T, E>
implements IntoResponse (as long as both T and E do), you can return one straight from a handler:
use Path;
use StatusCode;
use Json;
async
What just happened: the success arm returns Ok(Json(book)) → a 200 with a JSON body; the failure arm
returns Err(StatusCode::NOT_FOUND) → a bare 404. axum unwraps the Result and turns whichever side
you returned into the response. This is the seed of real error handling — in Phase 7 you'll replace
StatusCode with your own error type that implements IntoResponse, so ? inside a handler maps your
domain errors to clean HTTP responses. For now, the takeaway is just: a fallible handler returns a
Result, and both arms have to be response-able.
What actually makes something a handler
📝 You may have noticed you never registered your functions as handlers or implemented any interface.
That's because axum implements its Handler trait automatically for any async fn whose arguments all
implement the extractor traits (FromRequest / FromRequestParts) and whose return type implements
IntoResponse. You don't think about Handler directly — you just satisfy the two conditions on each end,
and the function is a handler. That's the whole trick: the type system, not a macro, decides what
.route() will accept.
⚠️ The error you'll eventually hit. When an argument or the return type doesn't satisfy those traits,
the compiler doesn't point at your function. It points at the .route(...) call and emits a long, scary
message like:
the trait bound `fn(...) -> ...: Handler<_, _>` is not satisfied
the following other types implement trait `Handler<T, S>` ...
required by a bound introduced by this call
The first time you see it, it looks like axum is broken. It isn't. It's saying: "this function doesn't qualify as a handler." Resist the urge to debug the router — the real fix is almost always in the function signature. Run down this checklist:
- Is every argument an extractor? A stray
&stror a custom struct that isn't an extractor will break it. - Does the return type implement
IntoResponse? Returning, say, a bareBook(when it isn't an extractor/response) won't compile — wrap it inJson. - Is the body-consuming extractor (
Json<T>,String,Bytes) the last argument? - Is the function
async?
Nine times out of ten, fixing the arguments or the return type makes the .route() error vanish. Read the
signature, not the router.
Recap
- A handler is just an
async fn: arguments extract from the request, the return value becomes the response. Memorize that, not signatures. Json<T>is bidirectional — an extractor for the request body (T: Deserialize, must be the last parameter) and a response for the body (T: Serialize).- The return type must implement
IntoResponse.&str/String,Json<T>,StatusCode, tuples like(StatusCode, Json<T>),Html<_>, andResult<T, E>all do. - The everyday create pattern is
(StatusCode::CREATED, Json(book));StatusCodelives inaxum::http::StatusCode. - A fallible handler returns
Result<T, E>where both sides areIntoResponse— the on-ramp to Phase 7's custom error type. - A "trait bound ...
Handleris not satisfied" error on.route(...)means the function signature is wrong (a non-extractor arg, a non-response return, orJsonnot last) — fix the handler, not the router.
Quick check
[
{
"q": "Why must a Json<T> extractor be the last parameter in a handler?",
"choices": ["Rust requires generic parameters to come last", "It consumes the request body, so it must come after extractors that only read the URL and headers", "axum reads parameters right-to-left", "Json is alphabetically last among extractors"],
"answer": 1,
"explain": "Reading the body consumes the request, so body-consuming extractors must come after non-consuming ones like Path and Query."
},
{
"q": "Which return type gives a 201 with the new book serialized as JSON?",
"choices": ["Book", "(StatusCode, Json<Book>) returning (StatusCode::CREATED, Json(book))", "Json<StatusCode>", "String"],
"answer": 1,
"explain": "axum implements IntoResponse for (StatusCode, T) tuples: the StatusCode sets the status and the Json<Book> becomes the body."
},
{
"q": "You get \"the trait bound ...: Handler<_, _> is not satisfied\" on a .route() call. Where is the real problem?",
"choices": ["The router configuration", "The handler's argument or return types don't satisfy the extractor / IntoResponse traits", "A missing dependency in Cargo.toml", "The Tokio runtime isn't started"],
"answer": 1,
"explain": "That error means the function doesn't qualify as a handler. Check the signature: every arg an extractor, the return IntoResponse, and the body extractor last."
}
]
← Phase 2: Routing & Extractors · Guide overview · Phase 4: Shared State →
Check your understanding
1. Why must a Json<T> extractor be the last parameter in a handler?
2. Which return type gives a 201 with the new book serialized as JSON?
3. You get "the trait bound ...: Handler<_, _> is not satisfied" on a .route() call. Where is the real problem?