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 Router sees the method and path of every request and picks one endpoint — or the fallback (404) or a 405 Method Not Allowed answer — before any extractor runs.

  • Because middleware added with Router::layer runs after routing, it cannot change which route matches; a layer that rewrites the URI (such as NormalizePathLayer) must wrap the whole Router.

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();
}
  • The free functions get, post, put, patch, delete, head, options, trace and connect each create a MethodRouter with one method; the methods of the same names on MethodRouter chain more methods onto it.

  • Calling route twice 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 with Overlapping 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. MethodFilter is a bit set with constants for the nine standard methods.

  • The _service variants (get_service, on_service, any_service, route_service) accept a tower Service instead 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();
}

Path syntax

Syntax Matches Extract with

/books

exactly /books (static segment)

 — 

/books/{isbn}

one segment: /books/9780441013593, not /books/a/b and not /books/

Path<String>, Path<u32>, …​

/books/{isbn}/reviews/{id}

several captures

Path<(String, u32)> or a Deserialize struct

/files/{*path}

one or more remaining segments: /files/a, /files/a/b.png

Path<String> (a/b.png, no leading /)

/books/isbn-{id}

a segment with a static prefix (or suffix) around one capture

Path<String>

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.

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.

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/new wins 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 second route call panics with Insertion 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 Allowed with an Allow header listing the registered methods; a path that does not match returns the fallback’s response (404 by 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:

  1. register both paths explicitly when both must work;

  2. pick one form for your API and let the other return 404 — the simplest choice for JSON APIs;

  3. 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();
}

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();
}

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.

Where the books are outdated

Crossley et al. (Rust for Scientific Computing, ch. 15) and many online tutorials write routes as /books/:id and /files/*path; since axum 0.8 those panic at start-up and must become /books/{id} and /files/{*path}. Yoon (Beginning Axum, chapter 2) uses the 0.8 syntax.