Routing and extracting path and query parameters
Step 3 of 15. The service answers one hardcoded question so far — /health says ok and nothing
else can be asked. This step makes the router take input: a path segment to resolve, a query
string to page with. It finishes with three routes — /health, /links?page=2&limit=10, and
/{code} — and with the one rule about extractor order that bites everyone who adds a fourth.
Start from the state step 2 left: the shortlink project, sixteen lines of src/main.rs, axum
and tokio in Cargo.toml. If you jumped straight here, git checkout api-ch02 in your clone of
the examples repository puts that state on disk.
The routes this step adds
Section titled “The routes this step adds”Two of them, and both are on the contract from step 1:
GET /links— the listing route. It reads?page=and?limit=from the query string.GET /{code}— the resolve route. A visitor hitshttps://…/9xK2mQzand the service answers; once the database exists (step 5) this becomes the 302 redirect. Until then it echoes the code it extracted, which is enough to prove the extraction works.
Both return plain text for now. Step 4 replaces the bodies with the JSON envelope, and the handlers stay — only their return types change.
One new dependency: serde
Section titled “One new dependency: serde”Path and Query are both part of axum, but the Query extractor deserialises into your struct
with serde, so the struct needs #[derive(Deserialize)]:
[dependencies]axum = { version = "0.8", features = ["macros"] }serde = { version = "1", features = ["derive"] }tokio = { version = "1", features = ["macros", "rt-multi-thread"] }The macros feature on axum is new in this step too. It buys #[axum::debug_handler], which
section “The order extractors must be in” puts to work. Without it the attribute does not exist:
error[E0433]: cannot find `debug_handler` in `axum` --> src/main.rs:13:9 | 13 | #[axum::debug_handler] | ^^^^^^^^^^^^^ could not find `debug_handler` in `axum` |note: found an item that was configured out --> /Users/rango/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/axum-0.8.9/src/lib.rs:526:23 |525 | #[cfg(feature = "macros")] | ------------------ the item is gated behind the `macros` feature526 | pub use axum_macros::{debug_handler, debug_middleware}; | ^^^^^^^^^^^^^The path and line are from this machine’s build; the note to read is the last one — the item is
gated behind the macros feature, which the Cargo.toml above already enables.
The whole main.rs now
Section titled “The whole main.rs now”use axum::{ Router, extract::{Path, Query}, routing::get,};use serde::Deserialize;
#[derive(Deserialize)]struct Pagination { page: Option<usize>, limit: Option<usize>,}
async fn health() -> &'static str { "ok"}
async fn list_links(Query(p): Query<Pagination>) -> String { format!("page {}, limit {}", p.page.unwrap_or(1), p.limit.unwrap_or(20))}
async fn resolve_link(Path(code): Path<String>) -> String { format!("will redirect /{code} to its target once the database exists")}
#[tokio::main]async fn main() { let app = Router::new() .route("/health", get(health)) .route("/links", get(list_links)) .route("/{code}", get(resolve_link));
let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap(); axum::serve(listener, app).await.unwrap();}Three routes on the chain now. The handlers are ordinary async functions whose arguments are extractors — types that know how to pull a piece of the request out of it. Look at the argument syntax, because it is doing two jobs at once:
Query(p): Query<Pagination>The type is Query<Pagination>, and the pattern Query(p) reaches inside the wrapper and binds
the inner Pagination to p in one move. Path(code): Path<String> does the same. You could
write let p: Pagination = Query::from_request_parts(...) by hand; the pattern in the argument
list is the shorthand every axum handler uses.
Path: naming a segment with braces
Section titled “Path: naming a segment with braces”The route pattern "/{code}" has a capture group in it. Whatever lands in that segment of the URL
becomes the String the handler receives:
curl -si http://127.0.0.1:8000/9xK2mQzHTTP/1.1 200 OKcontent-type: text/plain; charset=utf-8content-length: 61date: Sun, 18 Oct 2026 00:55:01 GMT
will redirect /9xK2mQz to its target once the database existsTwo things about that route pattern deserve attention.
First, /{code} matches exactly one segment. Two segments is a different shape:
curl -si http://127.0.0.1:8000/nope/hereHTTP/1.1 404 Not Foundcontent-length: 0date: Sun, 18 Oct 2026 00:55:01 GMTSecond, /{code} did not swallow /health or /links. The router prefers static paths over
capture groups, so the two specific routes win and the dynamic one picks up everything else —
which is exactly what a link shortener wants: unknown codes fall through to the resolver.
The braces are axum 0.8 syntax. Earlier tutorials write /links/:code, and under 0.8 that panics
before the server starts:
thread 'main' (12025958) panicked at src/main.rs:31:10: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.note: run with `RUST_BACKTRACE=1` environment variable to display a backtraceThe thread id and the 31:10 are from my file — yours will point at your .route line. The
message is the useful part: any segment starting with : is refused, so a pattern copy-pasted
from a 0.7 tutorial fails loudly at startup rather than silently never matching.
Query: everything after the ?
Section titled “Query: everything after the ?”Query<Pagination> deserialises the query string into the struct. Both fields are Option, and
absent fields stay None — the handler supplies defaults:
curl -si "http://127.0.0.1:8000/links?page=2&limit=10"HTTP/1.1 200 OKcontent-type: text/plain; charset=utf-8content-length: 16date: Sun, 18 Oct 2026 00:55:01 GMT
page 2, limit 10No query at all is fine too:
curl -si http://127.0.0.1:8000/linksHTTP/1.1 200 OKcontent-type: text/plain; charset=utf-8content-length: 16date: Sun, 18 Oct 2026 00:55:01 GMT
page 1, limit 20Option covers a missing field. It does not cover a wrong value. Change page from
Option<usize> to plain usize, restart, and the absent field becomes a rejection — axum turns
it into a 400 before your handler runs:
HTTP/1.1 400 Bad Requestcontent-type: text/plain; charset=utf-8content-length: 56date: Sun, 18 Oct 2026 00:37:28 GMT
Failed to deserialize query string: missing field `page`The same happens when the value will not parse, Option or not — ?page=abc against the code
above gives:
HTTP/1.1 400 Bad Requestcontent-type: text/plain; charset=utf-8content-length: 71date: Sun, 18 Oct 2026 00:54:39 GMT
Failed to deserialize query string: page: invalid digit found in stringThat text is the rejection, not your handler — there is no way to intercept it at this step. Step 10 builds the error type that turns bad input into proper status codes with a body you control; for now, “the extraction failed, here is why, 400” is honest behaviour and costs nothing.
The order extractors must be in
Section titled “The order extractors must be in”The rule: every handler argument except the last must implement FromRequestParts — read
something from the request head (path, query string, headers) and leave the request intact. Only
the last argument may implement FromRequest — consume the body. Path and Query are
parts extractors; String, Bytes, and (from step 4) Json are body extractors.
Put a body extractor anywhere but last and the build breaks. Watch what it looks like:
use axum::{Router, extract::Query, routing::get};use serde::Deserialize;
#[derive(Deserialize)]struct Pagination { page: Option<usize>,}
// reads the body first, then the query — does not compileasync fn bad_order(body: String, Query(p): Query<Pagination>) -> String { format!("{} {}", body, p.page.unwrap_or(1))}
#[tokio::main]async fn main() { let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap(); let app = Router::new().route("/links", get(bad_order)); axum::serve(listener, app).await.unwrap();}error[E0277]: the trait bound `fn(String, Query<Pagination>) -> impl Future<Output = String> {bad_order}: Handler<_, _>` is not satisfied --> src/main.rs:17:49 | 17 | let app = Router::new().route("/links", get(bad_order)); | --- ^^^^^^^^^ the trait `Handler<_, _>` is not implemented for fn item `fn(String, Query<Pagination>) -> impl Future<Output = String> {bad_order}` | | | required by a bound introduced by this call | = note: Consider using `#[axum::debug_handler]` to improve the error messagenote: required by a bound in `axum::routing::get` --> /Users/rango/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/axum-0.8.9/src/routing/method_routing.rs:167:16 |167 | H: Handler<T, S>, | ^^^^^^^^^^^^^ required by this bound in `get`...441 | top_level_handler_fn!(get, GET); | ------------------------------- | | | | | required by a bound in this function | in this macro invocation = note: this error originates in the macro `top_level_handler_fn` (in Nightly builds, run with -Z macro-backtrace for more info)
For more information about this error, try `rustc --explain E0277`.Read it twice and notice what it does not say: nothing about extractors, nothing about argument
order. The failure surfaces at the .route() call because that is where the Handler trait bound
is checked, and the concrete reason is one level of generics deeper than rustc reports. The error
points at the right line — but at the wrong party. Your handler is the problem, not the route.
The error message itself names the escape hatch. Put #[axum::debug_handler] on the same handler
and rustc says exactly what is wrong:
error: `String` consumes the request body and thus must be the last argument to the handler function --> src/main.rs:10:26 |10 | async fn bad_order(body: String, Query(p): Query<Pagination>) -> String { | ^^^^^^That is the whole diagnosis, pointed at the exact argument. The attribute costs nothing at
runtime — it only improves the error — and it lives behind the macros feature this step added to
Cargo.toml. Once a handler type-checks, the attribute has done its job; you can leave it on or
take it off.
Among parts extractors the order is free — Path before Query or the reverse compile and
behave the same. The convention you will see in every axum codebase is path, then query, then
headers, then body last, and this track follows it.
Where this leaves the service
Section titled “Where this leaves the service”The router now takes input: one path segment, one query string, three routes on the chain, and a
rule about extractor order that survives every handler you will write after this. The end state is
tagged api-ch03 in the examples repository —
git checkout api-ch03 inside axum-sqlx-api/ puts your tree where this article left it.
Everything is still plain text. Step 4 gives the handlers a response type of their own — one JSON
envelope with an IntoResponse impl — so /links starts returning structured data and the
interim strings here get replaced. The track overview keeps the full
list current as articles land.