You have a service to build
You have a microservice idea. Maybe a JSON API for a game leaderboard, or a health check endpoint for a dashboard. You've built this before in Python with Flask or JavaScript with Express. The pattern is familiar: define a route, write a function, start the server. Rust offers axum for this exact job. It feels like Express but with a type system that catches your mistakes before the server starts. You add the crate, write a handler, and hit run. The code compiles. The server starts. You curl the endpoint. It works. But the real work begins when you need to share state, handle errors, or process a request body. That's where axum's design reveals itself.
Extractors and responders
Axum treats HTTP as a data flow. You define a Router. You attach routes to it. Each route points to a handler. The handler is an async function. Axum inspects the function signature. If you ask for a String, axum tries to read the request body as text. If you ask for Json<MyStruct>, axum parses the body as JSON and deserializes it. If the client sends garbage, axum returns a 400 error automatically. You get validation for free. The return type works the same way. Return a String, and axum sends it as text. Return Json<MyStruct>, and axum serializes it. Return (StatusCode, Json<MyStruct>), and axum sets the status code.
This mechanism is called extraction and response. The arguments to your handler are extractors. They tell axum how to pull data out of the request. The return type is a responder. It tells axum how to format the response. You never parse headers manually. You never serialize JSON by hand. The types do the work.
Convention aside: axum is part of the Tower ecosystem. It accepts any Tower middleware. This means you can drop in logging, compression, or authentication middleware without axum-specific glue. The community expects this integration. If you write middleware for axum, write it as Tower middleware so others can reuse it.
Minimal server
Start with a router, a handler, and a listener. This is the skeleton of every axum application.
use axum::{routing::get, Router};
/// Starts the HTTP server on port 3000.
/// Binds to all network interfaces.
#[tokio::main]
async fn main() {
// Build the application structure.
// Router::new() creates an empty router.
// route() attaches a handler to a path.
// get() restricts the handler to GET requests.
let app = Router::new().route("/", get(handler));
// Bind to all interfaces on port 3000.
// 0.0.0.0 accepts connections from any IP.
// Use 127.0.0.1 for localhost-only binding.
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.expect("Failed to bind listener");
// Start serving.
// This blocks until the process ends.
axum::serve(listener, app)
.await
.expect("Server failed to start");
}
/// Handles GET requests to "/".
/// Returns a static string which axum converts to a 200 OK response.
async fn handler() -> &'static str {
"Hello, World!"
}
Add these dependencies to Cargo.toml:
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
Convention aside: Pin axum to the minor version. Axum follows semver, but 0.x releases can contain breaking changes in minor versions. Write axum = "0.7" not axum = "0". For tokio, features = ["full"] is standard for learning and small projects. Trim features later if you need a smaller binary.
Trust the types. If it compiles, the route exists.
How the request flows
When you call Router::new(), you get an empty router. Calling .route("/", get(handler)) adds a rule. The router now knows that GET requests to / should invoke handler. The get function wraps your handler to restrict it to the GET method. If a POST request hits /, axum returns a 405 Method Not Allowed.
The TcpListener opens a socket. axum::serve takes ownership of the listener and the router. It enters an event loop. When a connection arrives, axum reads the HTTP request. It matches the path against the router. If there's a match, axum calls the handler. It passes the request data to the handler via extractors. When the handler returns, axum formats the response and sends it back.
The handler runs on the tokio runtime. It must be async. If you block the handler, you block the entire server. Use tokio::task::spawn_blocking for CPU-heavy work. Never call synchronous blocking code directly in a handler.
Realistic API with state
Real applications share state. A database connection pool, a cache, or a list of users. Axum provides the State extractor for this. You create the state, attach it to the router, and extract it in handlers. The state must be thread-safe. Wrap it in Arc and a synchronization primitive like Mutex or RwLock.
use axum::{
extract::{Path, State},
response::Json,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use std::collections::HashMap;
/// Application state shared across all handlers.
/// Wrapped in Arc for shared ownership and Mutex for thread-safe mutation.
struct AppState {
users: Mutex<HashMap<u32, User>>,
}
#[derive(Deserialize)]
struct CreateUser {
name: String,
email: String,
}
#[derive(Serialize, Clone)]
struct User {
id: u32,
name: String,
email: String,
}
/// Handles GET /users/:id.
/// Extracts the id from the URL path and the app state.
async fn get_user(
State(state): State<Arc<AppState>>,
Path(id): Path<u32>,
) -> Result<Json<User>, (axum::http::StatusCode, String)> {
let users = state.users.lock().unwrap();
match users.get(&id) {
Some(user) => Ok(Json(user.clone())),
None => Err((axum::http::StatusCode::NOT_FOUND, "User not found".to_string())),
}
}
/// Handles POST /users.
/// Extracts JSON body and deserializes it.
/// Inserts the new user into the state.
async fn create_user(
State(state): State<Arc<AppState>>,
Json(payload): Json<CreateUser>,
) -> Json<User> {
let mut users = state.users.lock().unwrap();
let id = users.len() as u32 + 1;
let user = User {
id,
name: payload.name,
email: payload.email,
};
users.insert(id, user.clone());
Json(user)
}
#[tokio::main]
async fn main() {
// Create shared state.
let state = Arc::new(AppState {
users: Mutex::new(HashMap::new()),
});
// Attach state to the router.
let app = Router::new()
.route("/users", post(create_user))
.route("/users/{id}", get(get_user))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.expect("Failed to bind");
axum::serve(listener, app)
.await
.expect("Server failed");
}
Convention aside: Use State<Arc<T>> not State<T>. The State extractor clones the state for each request. Cloning an Arc is cheap. Cloning the inner data is expensive. The community expects State<Arc<T>> for performance. Also, add serde to your dependencies. The Json extractor and responder require serde and serde_json.
Let serde and axum fight the parsing battle. You focus on the logic.
Pitfalls and compiler errors
Handlers are called once per request. You cannot move a value out of a handler and expect it to persist. If you try to capture a non-Clone value in a handler closure, the compiler rejects you with E0382 (use of moved value). Handlers must be Clone themselves. Axum clones the handler for each request. If your handler captures state, that state must be Clone. This is why Arc is essential. Arc implements Clone by bumping a reference count.
Return types must implement IntoResponse. If you return a type that axum doesn't know how to serialize, you get E0277 (trait bound not satisfied). The error message lists the missing trait. Wrap the value in Json, Html, or Text. Or return a tuple with a StatusCode.
// This fails with E0277.
// MyStruct does not implement IntoResponse.
async fn bad_handler() -> MyStruct {
MyStruct { value: 1 }
}
// This works.
// Json<T> implements IntoResponse if T implements Serialize.
async fn good_handler() -> Json<MyStruct> {
Json(MyStruct { value: 1 })
}
Binding to 0.0.0.0 exposes your server to all network interfaces. This is correct for containers and production. It is risky for local development. Use 127.0.0.1 when you only want local access. The compiler won't warn you. The network stack decides.
Read the trait bound error. It tells you exactly what wrapper you're missing.
When to use axum
Use axum when you want a modern, type-safe web framework that leverages Rust's type system for routing and extraction. Use axum when you need middleware support, as it accepts any Tower middleware for logging, compression, or authentication. Use axum when you are building a JSON API and want automatic serialization with serde. Use actix-web when you require maximum throughput and have measured that axum's overhead is unacceptable for your specific workload. Use warp when you prefer a filter-based composition model where you chain filters to build routes. Use a raw tokio listener when you are implementing a custom binary protocol and don't need HTTP parsing.
Axum is the default choice for a reason. Start there.