The first request
You just finished a Python FastAPI tutorial. It took twenty minutes. You defined a route, returned a dictionary, and got a JSON response. Now you are looking at Rust. You want to do the exact same thing, but the ecosystem throws tokio, hyper, tower, and axum at you. The learning curve feels vertical. The reality is much flatter. Axum is just a routing layer that sits on top of Tokio's async runtime. Once you understand how it wires a URL to a function, the rest is standard Rust.
How Axum actually works
Think of Axum like a restaurant host stand. The host does not cook the food. The host takes your reservation, checks the table assignments, and hands you off to the right waiter. The waiter takes your order, passes it to the kitchen, and brings back the plate. Axum's job is purely coordination. It matches paths, extracts query parameters, parses JSON bodies, and hands the cleaned-up data to your functions. You write the functions. Axum handles the plumbing.
This separation matters because Rust refuses to guess. In Python, a web framework might silently convert a missing field to None or throw a vague 500 error. Rust forces you to declare exactly what your handler expects. If the request body is malformed, the framework returns a 400 Bad Request automatically. You do not write the error handling boilerplate. The type system does it for you.
Convention aside: the community treats cargo fmt as the final authority on whitespace. Do not argue indentation or bracket placement in code reviews. Argue logic, extraction order, and error boundaries instead. The formatter guarantees every project reads the same way.
A minimal server
Here is the smallest working server. It listens on port 3000 and responds to GET / with plain text.
use axum::{routing::get, Router};
/// Starts the HTTP server and binds to the local network.
#[tokio::main]
async fn main() {
// Build the router and attach a single route.
// The closure returns a future that resolves to a string literal.
let app = Router::new().route("/", get(|| async { "Hello, Axum!" }));
// Bind to all network interfaces on port 3000.
// Tokio handles the blocking socket setup in a background thread.
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
// Pass the listener and router to Axum's serve function.
// This blocks the main task until the process receives a termination signal.
axum::serve(listener, app).await.unwrap();
}
Run it with cargo run. Open http://localhost:3000. You see the text. The server stays alive. That is it. The #[tokio::main] attribute sets up the async runtime. Axum needs it because every incoming connection runs as a separate task. Without Tokio, the async keyword has nowhere to execute.
Walking through the request lifecycle
When a client connects, Tokio accepts the TCP socket and hands it to axum::serve. Axum upgrades the raw socket into an HTTP stream. It reads the first line of the request to get the method and path. It checks the router tree. If the path matches /, it invokes the closure you passed to get. The closure returns a future that resolves to &str. Axum takes that string, wraps it in an HTTP 200 response, sets the Content-Type to text/plain, and writes it back to the socket. The connection closes or stays alive depending on the Connection header.
Notice the closure signature: || async { "Hello, Axum!" }. Axum accepts any function that takes zero arguments and returns a type that implements IntoResponse. String literals, String, (), and even custom structs can work. The framework inspects the return type and picks the right headers and status code automatically.
The async runtime multiplexes thousands of connections on a handful of OS threads. When your handler awaits a database query or an external API call, the runtime parks that task and immediately picks up another connection. No thread sits idle waiting for network I/O. This is why Rust web servers handle high concurrency without spawning a thread per request. Trust the scheduler. It does the heavy lifting while you focus on business logic.
Building a realistic endpoint
Real APIs need structure. You want to accept JSON, return JSON, and handle multiple routes. Axum uses a pattern called extractors. Instead of passing raw Request objects to your handlers, you declare the exact pieces you need as function parameters. Axum pulls them out for you.
use axum::{
extract::{Json, Path},
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
/// Represents a user in the API.
#[derive(Serialize, Deserialize)]
struct User {
id: u64,
name: String,
}
/// Handles GET /users and returns a static list.
async fn list_users() -> Json<Vec<User>> {
// Return an owned vector to avoid lifetime issues across async boundaries.
let users = vec![
User { id: 1, name: "Alice".into() },
User { id: 2, name: "Bob".into() },
];
Json(users)
}
/// Handles POST /users and echoes the submitted data.
async fn create_user(Json(payload): Json<User>) -> Json<User> {
// The extractor already validated the JSON structure.
// In production, you would insert `payload` into a database here.
Json(payload)
}
/// Handles GET /users/{id} and returns a single user.
async fn get_user(Path(id): Path<u64>) -> Json<User> {
// Path extraction parses the URL segment and converts it to u64.
// If the segment is not a valid number, Axum returns 400 automatically.
Json(User { id, name: "Dynamic".into() })
}
#[tokio::main]
async fn main() {
// Chain routes together using the builder pattern.
let app = Router::new()
.route("/users", get(list_users).post(create_user))
.route("/users/{id}", get(get_user));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Look at create_user. The parameter Json(payload): Json<User> tells Axum to read the request body, parse it as JSON, and deserialize it into a User. If the JSON is invalid or a field is missing, Axum short-circuits and returns a 400 response before your function body even runs. You never write serde_json::from_str manually. The type signature is the contract.
Convention aside: the community prefers destructuring the extractor in the parameter list like Json(payload): Json<User> rather than payload: Json<User>. It keeps the variable name clean and makes the extraction explicit at a glance. You will see this pattern in every well-maintained Axum codebase.
Handling errors and status codes
Handlers do not have to return success responses. Axum supports returning Result<T, E> where E implements IntoResponse. This lets you use the ? operator inside async handlers without writing match statements.
use axum::{
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::Serialize;
/// Custom error type that implements IntoResponse.
#[derive(Serialize)]
struct ApiError {
status: u16,
message: String,
}
/// Converts the custom error into an HTTP response.
impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
// Map the internal status code to the HTTP status enum.
let status = StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
// Serialize the error struct to JSON and attach the correct status.
(status, Json(self)).into_response()
}
}
/// Simulates a database lookup that might fail.
async fn find_user() -> Result<Json<User>, ApiError> {
// Return an error instead of panicking on missing data.
Err(ApiError {
status: 404,
message: "User not found".into(),
})
}
The IntoResponse trait is the bridge between your domain errors and HTTP. You define how your error maps to status codes and response bodies. Axum handles the serialization and header injection. If you forget to implement IntoResponse for your error type, the compiler rejects the handler with E0277 (trait bound not satisfied). Add the implementation and the pipeline flows cleanly.
Common pitfalls and compiler friction
Newcomers hit three walls consistently. The first is forgetting that handlers must be async. If you define fn list_users() -> Json<Vec<User>> without the async keyword, the compiler rejects it. Axum requires handlers to return futures because the runtime needs to pause and resume them while waiting for I/O. Add async and the error vanishes.
The second wall is lifetime mismatches when returning borrowed data. If your handler tries to return a reference to a local variable, you get E0515 (cannot return reference to temporary value). Axum handlers run on separate tasks. The task might outlive the stack frame where the data was created. Return owned types like String or Vec<T> instead of &str or &[T]. The allocation cost is negligible compared to the safety guarantee.
The third wall is state sharing. You want to pass a database connection pool or a configuration struct to every handler. You cannot just capture it in a closure because closures do not implement Clone or Send by default. Axum solves this with State. You attach state to the router, then extract it in handlers. The Arc wrapper allows multiple async tasks to share the state safely across threads. You never pass raw pointers or use unsafe for shared configuration. Treat the State extractor as your single source of truth. It eliminates hidden dependencies between routes.
Choosing your async stack
Use Axum when you need a modern, ergonomic web framework that integrates tightly with the Tokio ecosystem and leverages Rust's type system for routing and extraction. Use Actix-web when you are migrating from an older codebase or need maximum raw throughput with a more traditional callback-based handler model. Use Poem when you want a lighter dependency footprint and prefer a macro-heavy routing syntax. Use plain Hyper when you are building a custom protocol adapter or need absolute control over the HTTP parsing layer without any routing abstractions. Reach for Axum's Router and extractors for 90 percent of REST APIs. The learning curve pays off in fewer runtime errors and cleaner handler signatures.