Web APIs with Axum
Most backend work today means writing web APIs: programs that receive HTTP requests and answer with JSON. A mobile app asks "is train 12001 late?", and your server replies with a small JSON object. Rust is a strong choice for such services because they run fast with little memory, and the compiler catches many bugs before deployment.
The most widely used Rust web framework is Axum, built by the Tokio team. In this lesson you will build a small train status API step by step with Axum 0.8 (0.8.9 in November 2026): routes, handlers, extractors that read the request for you, shared state, proper error responses, middleware and graceful shutdown. You need Async in Depth: Channels, JoinSet & Cancellation and Serde & JSON in Depth first.
How Axum is organised
Axum is a thin layer over three other crates: Tokio runs the async code, hyper speaks HTTP, and tower provides middleware. Your code has three parts:
- A handler is an ordinary
async fnthat returns something Axum can turn into a response, such as aString, aStatusCodeorJson<T>. - An extractor is a handler parameter that pulls data out of the request:
Pathfor parts of the URL,Queryfor?key=valuepairs,Jsonfor the body andStatefor data you share between requests. - A router maps a method and a path, such as
GET /trains/{number}, to a handler.
There are no macros on handlers and no special base types. If your function's parameters are extractors and its return type implements IntoResponse, it is a handler.
Building a train status API
You will build the API in seven steps:
- Create the project and add the crates.
- Start a server with one route.
- Store the data in shared state.
- Read the URL with
PathandQuery. - Turn errors into JSON responses.
- Accept JSON in a
POSTrequest. - Add middleware and a graceful shutdown.
Step 1: create the project
cargo new train-status
cd train-status
cargo add axum
cargo add tokio --features full
cargo add serde --features derive
cargo add serde_json
cargo add tower-http --features traceStep 2: a server with one route
Replace src/main.rs with:
use axum::{routing::get, Router};
#[tokio::main]
async fn main() {
let app = Router::new().route("/health", get(health));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("Listening on http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}
async fn health() -> &'static str {
"ok"
}Start it with cargo run, and in a second terminal ask for the page with curl (on Windows PowerShell type curl.exe, because plain curl may be an alias for a different command):
curl http://127.0.0.1:3000/health
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3000/nothing-hereok 404
get(health) says "for GET requests, call health". A path with no route gets 404 Not Found automatically. 127.0.0.1 accepts connections only from your own computer; a server inside a container binds to 0.0.0.0 instead.
Step 3: data in shared state
Every request runs in its own task, possibly on a different thread, so shared data must be safe to use from many threads at once. Wrap it in Arc (shared ownership) and a lock, as you learned in Fearless Concurrency. Add these types and a function with some made-up trains:
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize)]
struct Train {
number: u32,
name: String,
from: String,
to: String,
delay_min: u32,
}
type Db = Arc<RwLock<HashMap<u32, Train>>>;
fn sample_data() -> Db {
let list = [
(12001, "Pink City Express", "Jaipur", "Delhi"),
(12002, "Desert Queen", "Jaipur", "Jodhpur"),
(16301, "Backwater Mail", "Kochi", "Chennai"),
];
let mut map = HashMap::new();
for (number, name, from, to) in list {
let train = Train {
number,
name: name.to_string(),
from: from.to_string(),
to: to.to_string(),
delay_min: 0,
};
map.insert(number, train);
}
Arc::new(RwLock::new(map))
}RwLock lets many readers in at once but only one writer, which suits an API that reads far more often than it writes. Cloning a Db clones only the Arc pointer, so it is cheap. The lock here is the standard library one; that is fine as long as you never hold it across an .await.
Step 4: reading the URL with Path and Query
use axum::extract::{Path, Query, State};
use axum::Json;
#[derive(Deserialize)]
struct Filter {
from: Option<String>,
}
async fn list_trains(State(db): State<Db>, Query(filter): Query<Filter>) -> Json<Vec<Train>> {
let db = db.read().unwrap();
let mut list: Vec<Train> = db
.values()
.filter(|t| filter.from.as_deref().is_none_or(|city| t.from == city))
.cloned()
.collect();
list.sort_by_key(|t| t.number); // HashMap order is random; sort for stable output
Json(list)
}Each extractor is written as a pattern: State(db) unpacks the State wrapper and gives you the Db, and Query(filter) deserialises the query string into Filter with serde. Because from is an Option, /trains and /trains?from=Jaipur both work. Returning Json(list) serialises the vector and sets the Content-Type: application/json header.
Path works the same way. In the route /trains/{number}, the {number} part is captured, and Path<u32> parses it as a number.
Step 5: errors as responses
A handler can return Result<T, E> where both T and E implement IntoResponse. Create an error enum and decide, in one place, which status code and JSON body each error produces:
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde_json::json;
enum ApiError {
NotFound(u32),
BadDelay(u32),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
ApiError::NotFound(n) => (StatusCode::NOT_FOUND, format!("no train with number {n}")),
ApiError::BadDelay(m) => (
StatusCode::UNPROCESSABLE_ENTITY,
format!("a delay of {m} minutes is not believable"),
),
};
(status, Json(json!({ "error": message }))).into_response()
}
}
async fn get_train(State(db): State<Db>, Path(number): Path<u32>) -> Result<Json<Train>, ApiError> {
let db = db.read().unwrap();
db.get(&number).cloned().map(Json).ok_or(ApiError::NotFound(number))
}A tuple of (StatusCode, body) is itself a response, which is why the last line of into_response is so short. Clients now always receive JSON, even for errors, and the handler stays clean: it returns ApiError::NotFound and does not care how that is shown.
Step 6: accepting JSON
Station staff report delays with a POST request whose body is JSON such as {"minutes": 25}:
#[derive(Deserialize)]
struct DelayReport {
minutes: u32,
}
async fn report_delay(
State(db): State<Db>,
Path(number): Path<u32>,
Json(report): Json<DelayReport>,
) -> Result<Json<Train>, ApiError> {
if report.minutes > 600 {
return Err(ApiError::BadDelay(report.minutes));
}
let mut db = db.write().unwrap();
let train = db.get_mut(&number).ok_or(ApiError::NotFound(number))?;
train.delay_min = report.minutes;
Ok(Json(train.clone()))
}Extractors run from left to right, and the request body can be read only once, so the body extractor (Json, String or Bytes) must be the last parameter. Put it anywhere else and the handler does not compile. If the body is not valid JSON, or the Content-Type header is missing, Axum rejects the request before your code runs.
Step 7: routes, middleware and graceful shutdown
Now connect everything in main:
use axum::routing::post;
use tower_http::trace::TraceLayer;
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/health", get(health))
.route("/trains", get(list_trains))
.route("/trains/{number}", get(get_train))
.route("/trains/{number}/delay", post(report_delay))
.layer(TraceLayer::new_for_http())
.with_state(sample_data());
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("Listening on http://127.0.0.1:3000");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.unwrap();
}
async fn shutdown_signal() {
tokio::signal::ctrl_c().await.expect("could not listen for Ctrl+C");
println!("Shutting down, finishing open requests");
}with_statehands theDbto every handler that asks forState<Db>. A router that still lacks its state cannot be served, so forgetting this is a compile error, not a crash.layerwraps the routes in middleware, code that runs around every request.TraceLayerrecords each request and response astracingevents; install a subscriber as shown in Rust at Work: CLI Tools, Services & Embedded to see them. tower-http also has layers for CORS, timeouts and compression.with_graceful_shutdownstops accepting new connections when Ctrl+C is pressed and lets requests already in progress finish.
Trying the API
With the server running, these bash commands exercise every route:
curl http://127.0.0.1:3000/trains/12001
curl "http://127.0.0.1:3000/trains?from=Jaipur"
curl -X POST http://127.0.0.1:3000/trains/16301/delay -H "Content-Type: application/json" -d '{"minutes":25}'
curl http://127.0.0.1:3000/trains/99999
curl -s -w "\nstatus %{http_code}\n" -X POST http://127.0.0.1:3000/trains/16301/delay -d '{"minutes":25}'{"number":12001,"name":"Pink City Express","from":"Jaipur","to":"Delhi","delay_min":0}
[{"number":12001,"name":"Pink City Express","from":"Jaipur","to":"Delhi","delay_min":0},{"number":12002,"name":"Desert Queen","from":"Jaipur","to":"Jodhpur","delay_min":0}]
{"number":16301,"name":"Backwater Mail","from":"Kochi","to":"Chennai","delay_min":25}
{"error":"no train with number 99999"}
Expected request with `Content-Type: application/json`
status 415curl does not add a newline after a response body, so in your terminal the prompt appears right after each answer; the output above puts each response on its own line for reading. The last command forgot the -H header, so Axum's Json extractor rejected it with 415 Unsupported Media Type and a plain-text message. Badly formed JSON gives 400 Bad Request, and well-formed JSON with the wrong shape (for example "minutes":"late") gives 422 Unprocessable Entity. You can write tests for all of these without starting a server; the Project: Orders API with Axum & Tests shows how.
Common mistakes
- Putting
Jsonbefore another extractor. The body extractor must be last; the compiler error mentions that the function is not a validHandler. - Forgetting
with_state. The router's type then says the state is missing, andaxum::serverefuses it. - Holding a
std::synclock across.await. The handler's future stops beingSendand Axum rejects it. Copy what you need out of the lock first. - Using
unwrapon user input in handlers. One bad request would panic that task. ReturnResultwith an error type that implementsIntoResponse. - Testing with the wrong curl. In Windows PowerShell, use
curl.exeand mind the different quoting rules, or use Git Bash.
Key points
- Axum maps routes such as
GET /trains/{number}to async handler functions;axum::serveruns the router on a TokioTcpListener. - Extractors (
Path,Query,Json,State) parse the request with serde before your code runs; the body extractor must come last. - Shared state goes through
with_state, usually as anArcholding a lock, a database pool or an actor's sender. - Anything that implements
IntoResponsecan be returned; a custom error enum gives consistent JSON errors. - tower-http layers add tracing, CORS, timeouts and compression around every route.
with_graceful_shutdownlets running requests finish when the server is asked to stop.
Exercise
Add a route GET /trains/late that returns only trains whose delay_min is more than 15, sorted by delay with the latest first. Then report a 40-minute delay for train 12002 and a 10-minute delay for train 12001 with curl, and call the new route. (Hint: register /trains/late as its own route; Axum prefers a fixed path over a {number} capture.)
Show solution
async fn late_trains(State(db): State<Db>) -> Json<Vec<Train>> {
let db = db.read().unwrap();
let mut late: Vec<Train> = db.values().filter(|t| t.delay_min > 15).cloned().collect();
late.sort_by_key(|t| std::cmp::Reverse(t.delay_min));
Json(late)
}
// in main, next to the other routes:
// .route("/trains/late", get(late_trains))curl -X POST http://127.0.0.1:3000/trains/12002/delay -H "Content-Type: application/json" -d '{"minutes":40}'
curl -X POST http://127.0.0.1:3000/trains/12001/delay -H "Content-Type: application/json" -d '{"minutes":10}'
curl http://127.0.0.1:3000/trains/late{"number":12002,"name":"Desert Queen","from":"Jaipur","to":"Jodhpur","delay_min":40}
{"number":12001,"name":"Pink City Express","from":"Jaipur","to":"Delhi","delay_min":10}
[{"number":12002,"name":"Desert Queen","from":"Jaipur","to":"Jodhpur","delay_min":40}]std::cmp::Reverse flips the sort order, so the biggest delay comes first. Train 12001 is only 10 minutes late, so it is not in the list.
Quick Quiz
1. Which extractor reads the {number} part of the route /trains/{number}?
Path<T> deserialises the captured segments of the URL.
2. Why must Json<T> be the last parameter of a handler?
Only the last extractor may take the body; the others read only the request parts.
3. What does a handler that returns Err(ApiError::NotFound(7)) send to the client in this lesson?
Implementing IntoResponse for your error type controls the status code and body in one place.
4. What does with_graceful_shutdown(shutdown_signal()) do?
The server waits for the given future, then drains open requests before serve returns.
Learn Rust with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
