Routing
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against axum 0.8.9 on Rust 1.99 (edition 2024) with tower-http 0.7.1, checked on 2026-10-06. axum
0.8 changed the path syntax from /:id and /*rest to /{id} and /{*rest}; every example here uses the new
syntax, and the section on migrating explains what happens to old routes. Combining routers with nest, merge and
fallbacks is covered on the next page, Routing Composition.
Where it runs in the request
In the request lifecycle (tokio accepts
the connection, hyper parses the request, tower layers wrap the router, the router matches a route, middleware and
extractors run, the handler runs, the response travels back out), routing is the step after hyper and any outer
tower layers, and before the middleware added with Router::layer, the extractors and the handler:
-
The
Routersees the method and path of every request and picks one endpoint — or the fallback (404) or a405 Method Not Allowedanswer — before any extractor runs. -
Because middleware added with
Router::layerruns after routing, it cannot change which route matches; a layer that rewrites the URI (such asNormalizePathLayer) must wrap the wholeRouter.
Routes and method routers
A Router maps paths to method routers, and a MethodRouter maps HTTP methods to handlers. route(path,
method_router) registers both levels at once:
use axum::{
Router,
extract::Path,
routing::{delete, get, post},
};
async fn list_books() -> &'static str { "all books" }
async fn create_book() -> &'static str { "created" }
async fn show_book(Path(isbn): Path<String>) -> String { format!("book {isbn}") }
async fn replace_book(Path(isbn): Path<String>) -> String { format!("replaced {isbn}") }
async fn delete_book(Path(isbn): Path<String>) -> String { format!("deleted {isbn}") }
async fn lend_book(Path(isbn): Path<String>) -> String { format!("lent {isbn}") }
fn books_router() -> Router {
Router::new()
.route("/api/v1/books", get(list_books).post(create_book))
.route("/api/v1/books/{isbn}", get(show_book).put(replace_book).delete(delete_book))
.route("/api/v1/books/{isbn}/loans", post(lend_book))
// a separate `route` call for the same path adds methods to the existing MethodRouter:
.route("/api/v1/books/{isbn}/copies", delete(|| async { "copies removed" }))
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, books_router()).await.unwrap();
}
Source: axum: Router::route
-
The free functions
get,post,put,patch,delete,head,options,traceandconnecteach create aMethodRouterwith one method; the methods of the same names onMethodRouterchain more methods onto it. -
Calling
routetwice with the same path merges the method routers, so you may spread one path’s methods over several calls (or modules). Registering the same method twice for a path panics withOverlapping method route. Handler for `+GET /books/{id}already exists+`. -
any(handler)accepts every method;on(MethodFilter::GET.or(MethodFilter::POST), handler)accepts a chosen set.MethodFilteris a bit set with constants for the nine standard methods. -
The
_servicevariants (get_service,on_service,any_service,route_service) accept a towerServiceinstead of a handler (see Tower Service Model).
use axum::{
Router,
http::Method,
routing::{MethodFilter, any, on},
};
async fn search(method: Method) -> String {
format!("search via {method}")
}
async fn webhook(method: Method) -> String {
format!("webhook received a {method}")
}
fn app() -> Router {
Router::new()
// GET with a query string, or POST with a form body: same handler
.route("/search", on(MethodFilter::GET.or(MethodFilter::POST), search))
// every method, e.g. for a proxy or a webhook receiver
.route("/hooks/github", any(webhook))
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app()).await.unwrap();
}
Source: axum::routing::on and MethodFilter
Path syntax
| Syntax | Matches | Extract with |
|---|---|---|
|
exactly |
— |
|
one segment: |
|
|
several captures |
|
|
one or more remaining segments: |
|
|
a segment with a static prefix (or suffix) around one capture |
|
Captures are named, and the name is what a Deserialize struct’s field must be called when you extract into a struct
(see Extractors). A wildcard must be the last segment and never matches an empty
remainder: /files/{*path} does not match /files or /files/. Register /files separately if you need it.
Percent-encoded characters are decoded before extraction, and Path rejects captures that are not valid UTF-8.
Segments that mix static text and a capture (isbn-{id}) match in axum 0.8.9, because they are supported by the
matchit 0.8 router underneath, but the axum 0.8 documentation does not describe them; the unreleased 0.9 documents
them as part of the path syntax. Prefer whole-segment captures in public APIs until then.
Source: axum: Router::route — Wildcards
Old /:id routes panic
axum 0.8 refuses paths whose segments start with : or *, because they are almost certainly 0.7-style captures that
would now match literally:
thread 'main' panicked at src/main.rs:12: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.
Source: axum: Router::route — Wildcards
The panic happens when the router is built, at start-up, so a test that constructs the application catches it.
Router::without_v07_checks() disables the check for the rare API that really has literal : segments. To match a
literal { or }, double it: /{{literal}}.
Matching and priority
axum uses the matchit crate, a radix-tree router. Its rules are simple and independent of registration order:
-
Static beats dynamic.
/books/newwins over/books/{isbn}for the path/books/new, whichever was registered first. -
Captures beat wildcards.
/books/{isbn}wins over/{*rest}for/books/42. -
Conflicting captures panic. Two routes that differ only in the name of a capture at the same position —
/books/{id}and/books/{isbn}— cannot be told apart, and the secondroutecall panics withInsertion failed due to conflict with previously registered route: /books/{id}. -
Matching happens first on the path, then on the method. A path that matches with the wrong method returns
405 Method Not Allowedwith anAllowheader listing the registered methods; a path that does not match returns the fallback’s response (404by default).
use axum::{Router, extract::{MatchedPath, Path}, routing::get};
fn app() -> Router {
Router::new()
.route("/books/new", get(|| async { "the new-book form" })) // static
.route("/books/{isbn}", get(|Path(isbn): Path<String>| async move { format!("book {isbn}") }))
.route("/files/{*path}", get(|Path(p): Path<String>| async move { format!("file {p}") }))
.route("/{*rest}", get(|m: MatchedPath| async move { format!("catch-all {}", m.as_str()) }))
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app()).await.unwrap();
}
Source: matchit 0.8
$ curl localhost:3000/books/new
the new-book form
$ curl localhost:3000/books/9780441013593
book 9780441013593
$ curl localhost:3000/files/covers/dune.jpg
file covers/dune.jpg
$ curl localhost:3000/books/
catch-all /{*rest}
Source: matchit 0.8
The last request shows the trailing-slash rule: /books/ is not /books/{isbn} with an empty capture, so it falls
through to the catch-all (or to a 404 without one). MatchedPath returns the route template, which is what you want
in metrics labels and span names — never the raw path, which would create one time series per book.
Trailing slashes
axum treats /books and /books/ as different paths and does no redirecting (versions before 0.6 redirected
automatically). You have three options:
-
register both paths explicitly when both must work;
-
pick one form for your API and let the other return
404— the simplest choice for JSON APIs; -
normalise the path before routing with tower-http’s
NormalizePathLayer.
The third option has a twist: a layer added with Router::layer runs after the router has matched the path, so it
cannot change which route matches. NormalizePathLayer must wrap the whole Router from the outside:
use axum::{Router, ServiceExt, extract::Request, routing::get};
use tower::Layer;
use tower_http::normalize_path::NormalizePathLayer;
#[tokio::main]
async fn main() {
let router = Router::new().route("/books", get(|| async { "all books" }));
// `/books/` is rewritten to `/books` before the router sees it.
let app = NormalizePathLayer::trim_trailing_slash().layer(router);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, ServiceExt::<Request>::into_make_service(app)).await.unwrap();
}
Source: tower-http: normalize_path
axum::ServiceExt::into_make_service is needed because the wrapped value is no longer a Router, and axum::serve
needs to know how to create one service per connection. NormalizePathLayer::append_trailing_slash() normalises in the
other direction.
HEAD and OPTIONS
A get route also answers HEAD: axum runs the GET handler and discards the body, keeping the headers (including
content-length). When producing the body is expensive — rendering a report or reading a file — register an explicit
head handler or check the method, as the official handle-head-request example does:
use axum::{
Router,
http::{HeaderMap, Method, header},
response::IntoResponse,
routing::get,
};
async fn catalogue_export(method: Method) -> impl IntoResponse {
let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_TYPE, "text/csv".parse().unwrap());
if method == Method::HEAD {
return (headers, String::new()); // headers only: skip the expensive export
}
(headers, "isbn,title\n9780441013593,Dune\n".to_owned())
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/exports/catalogue.csv", get(catalogue_export));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
OPTIONS requests are not answered automatically — they get 405 unless a route registers options(...). For CORS
preflight requests, add CorsLayer, which answers them before routing matters (see
CORS & Compression).
Per-route middleware
MethodRouter::layer wraps the handlers of one route (all methods registered before the call), and route_layer does
the same but only runs for matched methods. This is how a single endpoint gets a stricter timeout or body limit:
use std::time::Duration;
use axum::{Router, extract::DefaultBodyLimit, http::StatusCode, routing::{get, post}};
use tower_http::timeout::TimeoutLayer;
async fn upload_cover() -> &'static str { "uploaded" }
async fn export() -> &'static str { "export" }
fn app() -> Router {
Router::new()
.route(
"/api/v1/books/{isbn}/cover",
post(upload_cover).layer(DefaultBodyLimit::max(5 * 1024 * 1024)), // 5 MiB for this route only
)
.route(
"/api/v1/exports",
get(export).layer(TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, Duration::from_secs(60))),
)
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app()).await.unwrap();
}
Source: axum: MethodRouter::layer
Router-wide layers, route_layer and the order in which layers run are covered on
Applying Middleware & Ordering.
Upcoming in axum 0.9
The main branch, unreleased at the time of writing, adds support for the HTTP QUERY method (a safe, idempotent
method with a request body, being standardised by the IETF HTTP working group) to MethodFilter and the method-router
functions, and documents segments that mix static text and captures. Neither changes how existing 0.8 routes behave.
The full list is on Migration & Framework Comparison.