Routing & Extractors
In Phase 1 you got a single route answering a single path. That's the whole Router in miniature, but real APIs branch: GET /books lists, POST /books creates, GET /books/42 shows one. This phase is about how axum decides which handler runs, and — the part that makes axum feel like magic until you see it — how a handler reaches into the request and pulls out exactly the data it wants.
📝 The mental model, and it's the whole framework: a route is method + path → handler. And a handler's parameters are extractors — each one is a type that knows how to pull a specific piece out of the incoming request. Path<u32> pulls a URL segment. Query<T> pulls the query string. Later you'll meet Json<T> (the body) and State<T> (shared data). You don't parse the request yourself; you declare what you need by type, and axum fills it in before your function body ever runs.
Hold that one sentence — "parameters are extractors that pull typed data from the request" — and nothing in this guide will surprise you.
Methods: one path, many verbs
A route ties an HTTP method to a handler. The method helpers live in axum::routing: get, post, put, delete, patch. You can chain them on a single path, which is exactly what you want for a REST resource.
We're growing a small books API this whole guide. Each book is just:
Here's the router for it:
use ;
async
async
async
What just happened: The first route maps two verbs to the same path — get(list_books).post(create_book). A GET /books runs list_books; a POST /books runs create_book; anything else on that path (say DELETE) gets an automatic 405 Method Not Allowed. The second route introduces a path parameter: {id} is a capture, a placeholder that matches any single segment. So /books/42 and /books/abc both match /books/{id} — but the handler hasn't read that segment yet. That's the extractor's job, next.
💡 A version note that will save you a confusing afternoon: the {id} curly-brace syntax is axum 0.8. If you're reading older blog posts or a 0.7 codebase, captures looked like :id ("/books/:id"). Same idea, different punctuation. This guide uses {id} throughout; if your compiler complains about the braces, check your axum version in Cargo.toml.
The Path extractor: reading the URL
A capture in the route is only half the deal. To actually use 42, you add a Path parameter to the handler:
use Path;
async
What just happened: Path<u32> is the extractor. axum looks at the matched route, finds the {id} segment, tries to parse it as a u32, and hands it to your function. The Path(id) part is just Rust pattern-matching — Path is a tuple struct wrapping one value, so Path(id) destructures it and binds the inner u32 to id. (If you find that line noisy, you could write path: Path<u32> and use path.0 instead — same thing, less idiomatic.)
The parse is real and it matters: a request to /books/42 gives you id = 42. A request to /books/abc can't parse as u32, so axum rejects it with 400 Bad Request before your handler runs. You never see the bad input — the type is the validation.
When a path has multiple captures, Path extracts a tuple, in order:
use Path;
// route: .route("/authors/{author}/books/{id}", get(show_authored_book))
async : )
What just happened: Two captures ({author}, {id}) map to a two-element tuple (String, u32), positionally — first segment to the first type, second to the second. So /authors/tolkien/books/7 gives author = "tolkien", id = 7. Note author is a String (any text is valid) while id is still a u32 (must parse as a number, or it's a 400). Order is everything here; the names in the URL don't matter to a tuple, only the position does.
The Query extractor: reading the query string
URLs carry data after the ? too — /books?page=2&limit=20. That's the query string, and Query<T> extracts it into a struct of your own design. This one needs serde, the Rust serialization library, because axum deserializes the raw page=2&limit=20 text into your typed struct.
Add serde with the derive feature:
Then define a struct describing the parameters you expect, and extract it:
use Query;
use Deserialize;
async
What just happened: #[derive(Deserialize)] teaches Pagination how to be built from the query string. Query<Pagination> then parses ?page=2&limit=20 into Pagination { page: Some(2), limit: Some(20) }. The two fields are Option<u32>, which is doing real work: it makes both parameters optional. A bare GET /books with no query string still succeeds — both fields come back None, and unwrap_or supplies sensible defaults. If you'd typed them as plain u32 instead of Option<u32>, a request missing page would be rejected with a 400. That choice — Option vs. required — is how you encode "optional vs. mandatory" directly in the type.
💡 The same pattern handles filters and flags: a field author: Option<String> lets /books?author=tolkien flow straight into a typed field. The struct is your query API.
Nesting and merging: structure for a growing API
One flat Router works until you have a dozen routes and want to version them, or split them across files. Two methods compose routers:
nest("/prefix", other)mounts a sub-router under a path prefix. Everything insideothergains that prefix. This is your versioning tool.merge(other)folds another router's routes into this one at the same level, no prefix. Good for splitting routes across modules without changing their paths.
Here's the books API mounted under /api/v1:
use ;
What just happened: books_router() defines paths as if they lived at the root — /books, /books/{id}. Then nest("/api/v1", ...) prefixes them all, so the real, reachable URLs become /api/v1/books and /api/v1/books/{id}. When v2 arrives, you write a books_router_v2() and .nest("/api/v2", ...) it alongside — the v1 routes keep working, untouched. That's why nest is the natural home for versioning: the prefix lives in one place, not sprinkled across every route string. Reach for merge instead when you're combining routers that should share the same prefix level — say, a users_router() and books_router() both living under /api/v1.
⚠️ A rule that'll bite you in Phase 3, so plant it now: an extractor that consumes the request body — like Json<T>, which you'll meet next phase for reading POST payloads — can appear only once per handler, and it must be the last parameter. The body is a stream you can read exactly once, so axum enforces this at compile time. Path and Query don't touch the body, so they can come in any order and any number. The moment you add a body extractor, it goes at the end: async fn create_book(Path(id): Path<u32>, Json(body): Json<NewBook>). Get the order wrong and you'll get a trait-bound error that looks scary but means exactly this. (Full story in Phase 3.)
Recap
- A route is method + path → handler; chain verbs on one path with
get(h).post(h2), and unmatched methods auto-return405. - Path captures use
{id}in axum 0.8 (older:idin 0.7); a capture in the route only matches a segment — an extractor reads it. Path<T>pulls URL segments by type (a tuplePath<(A, B)>for multiple, positionally); a parse failure is an automatic400.Query<T>deserializes the query string into a#[derive(Deserialize)]struct (needs serde);Option<_>fields make parameters optional.nest("/prefix", r)mounts a sub-router under a prefix (use it for versioning like/api/v1);merge(r)combines routers at the same level.- Body-consuming extractors (e.g.
Json) must be the last parameter and appear once — non-body extractors likePath/Queryhave no such limit.
Quick check
[
{
"q": "In axum 0.8, how do you declare a path that captures a book id?",
"choices": ["\"/books/:id\"", "\"/books/{id}\"", "\"/books/<id>\"", "\"/books/[id]\""],
"answer": 1,
"explain": "axum 0.8 uses curly braces: \"/books/{id}\". The colon form \":id\" was the 0.7 syntax."
},
{
"q": "A handler takes Query(params): Query<Pagination> where page is Option<u32>. What happens on a request to /books with no query string?",
"choices": ["It returns 400 Bad Request", "It succeeds and page is None", "It panics", "It returns 404 Not Found"],
"answer": 1,
"explain": "Because page is Option<u32>, the parameter is optional. Missing it yields None and the handler runs normally. A plain u32 field would have forced a 400."
},
{
"q": "Which statement about extractor order in a handler is true?",
"choices": ["Path must always come first", "A body extractor like Json must be the last parameter and appear only once", "Query must be the last parameter", "Order never matters for any extractor"],
"answer": 1,
"explain": "The request body can be read only once, so a body-consuming extractor (Json) must be last and singular. Non-body extractors like Path and Query can appear in any order."
}
]
← Phase 1: What axum Is & Your First Server · Guide overview · Phase 3: Handlers & IntoResponse →
Check your understanding
1. In axum 0.8, how do you declare a path that captures a book id?
2. A handler takes Query(params): Query<Pagination> where page is Option<u32>. What happens on a request to /books with no query string?
3. Which statement about extractor order in a handler is true?