Axum Tutorial: Build a REST API in Rust (Complete Guide 2026)
Axum Tutorial: Build a REST API in Rust (Complete Guide 2026)
Axum has become the most popular Rust web framework. Backed by the Tokio team, it combines ergonomic API design with the full power of the Tower middleware ecosystem. If you're building a backend in Rust in 2026, Axum is where you should start.
This guide walks you through everything you need to know - from your first route to a production-ready CRUD API. Every code example in this article compiles and runs against Axum 0.8 (the latest stable release as of March 2026).
Table of Contents
- Why Axum?
- Quick Start: Hello World in 60 Seconds
- Routing Fundamentals
- Handler Functions and Responses
- Extractors: Path, Query, JSON, Form
- Application State with
State - Error Handling That Scales
- Middleware with Tower
- Database Integration with SQLx
- Authentication and Authorization
- Serving Static Files and SPAs
- WebSockets
- Testing Without a Running Server
- Project Structure for Large Applications
- Deployment
- Axum vs Actix Web vs Rocket
- FAQ
Why Axum?
Before we write any code, here's why Axum has overtaken every other Rust web framework in adoption:
- No routing macros required. Routes are plain Rust functions. No
#[get("/")]decorators, no magic - just types and functions. - Tower compatibility. Every Tower middleware ever written works with Axum out of the box. Rate limiting, tracing, compression, CORS - plug it in.
- Backed by Tokio. Axum is maintained by the same team that builds the Rust async runtime that powers most of the ecosystem.
- Type-safe extractors. The compiler tells you when you're doing it wrong. Path parameters, query strings, JSON bodies - they're all checked at compile time.
- Best-in-class error messages. Axum 0.8 introduced improved compiler diagnostics that tell you exactly what's wrong when a handler doesn't satisfy trait bounds.
Axum at a Glance
| Feature | Axum 0.8 |
|---|---|
| Async Runtime | Tokio |
| Middleware System | Tower / tower-http |
| Macro-free routing | Yes |
| Path parameter syntax | /{id} (new in 0.8) |
| Native async traits | Yes (no #[async_trait] needed) |
| WebSocket support | Built-in |
| HTTP/2 support | Via feature flag |
| OpenAPI generation | Via utoipa crate |
Quick Start: Hello World in 60 Seconds
Create a new project and add dependencies:
cargo new axum-api
cd axum-api
cargo add axum@0.8 tokio@1 -F tokio/fullReplace src/main.rs:
use axum::{routing::get, Router};
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(|| async { "Hello, World!" }));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Listening on http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}cargo run
# In another terminal:
curl http://localhost:3000
# => Hello, World!That's a working HTTP server in 10 lines of Rust. No macros, no boilerplate, no framework magic. Let's build on it.
Routing Fundamentals
Axum's Router maps HTTP methods and paths to handler functions:
use axum::{routing::get, Router};
async fn index() -> &'static str { "Home" }
async fn about() -> &'static str { "About" }
let app = Router::new()
.route("/", get(index))
.route("/about", get(about));Multiple Methods on One Route
Chain method handlers on a single path:
use axum::routing::{get, post};
async fn list_users() -> &'static str { "List users" }
async fn create_user() -> &'static str { "Create user" }
let app = Router::new()
.route("/users", get(list_users).post(create_user));Path Parameters (New {param} Syntax in 0.8)
Axum 0.8 changed path parameter syntax from :param to {param}, aligning with OpenAPI and Rust's format!() macro:
// Axum 0.8+ syntax
.route("/users/{id}", get(get_user))
.route("/files/{*path}", get(serve_file)) // wildcard
// Old syntax (pre-0.8) - no longer works:
// .route("/users/:id", get(get_user))Migration note: If you're upgrading from Axum 0.7, search and replace
:paramwith{param}and*paramwith{*param}across all your route definitions.
Nested Routers
Split routes by feature using nest:
let user_routes = Router::new()
.route("/", get(list_users).post(create_user))
.route("/{id}", get(get_user).put(update_user).delete(delete_user));
let app = Router::new()
.nest("/api/users", user_routes);
// Produces: GET /api/users, POST /api/users, GET /api/users/{id}, etc.Merging Routers
When you want to combine routers that each define their own full paths:
let app = Router::new()
.merge(user_routes())
.merge(health_routes());Handler Functions and Responses
A handler is any async function that:
- Takes zero or more extractors as arguments
- Returns something that implements
IntoResponse
Returning Different Response Types
Axum implements IntoResponse for many types out of the box:
use axum::Json;
use axum::http::StatusCode;
use axum::response::Html;
// Plain text
async fn plain() -> &'static str {
"Hello"
}
// Status code only
async fn no_content() -> StatusCode {
StatusCode::NO_CONTENT
}
// JSON
async fn json() -> Json<serde_json::Value> {
Json(serde_json::json!({ "message": "Hello" }))
}
// HTML
async fn page() -> Html<&'static str> {
Html("<h1>Hello</h1>")
}
// Tuple: (StatusCode, Body)
async fn created() -> (StatusCode, Json<serde_json::Value>) {
(StatusCode::CREATED, Json(serde_json::json!({ "id": 1 })))
}Custom Response Types with IntoResponse
For production APIs, define an enum for your responses:
use axum::{response::{IntoResponse, Response}, http::StatusCode, Json};
use serde::Serialize;
#[derive(Serialize)]
struct User {
id: u64,
name: String,
}
enum ApiResponse {
Ok,
Created,
JsonData(Vec<User>),
}
impl IntoResponse for ApiResponse {
fn into_response(self) -> Response {
match self {
Self::Ok => StatusCode::OK.into_response(),
Self::Created => StatusCode::CREATED.into_response(),
Self::JsonData(data) => (StatusCode::OK, Json(data)).into_response(),
}
}
}
async fn list_users() -> ApiResponse {
ApiResponse::JsonData(vec![
User { id: 1, name: "Alice".into() },
])
}Extractors: Path, Query, JSON, Form
Extractors pull data out of incoming HTTP requests. They're passed as function parameters and Axum resolves them automatically.
Path Parameters
use axum::extract::Path;
async fn get_user(Path(id): Path<u64>) -> String {
format!("User #{id}")
}
// Multiple path params
async fn get_comment(
Path((post_id, comment_id)): Path<(u64, u64)>,
) -> String {
format!("Post {post_id}, Comment {comment_id}")
}
// Route: .route("/posts/{post_id}/comments/{comment_id}", get(get_comment))Query Parameters
use axum::extract::Query;
use serde::Deserialize;
#[derive(Deserialize)]
struct Pagination {
page: Option<u32>,
per_page: Option<u32>,
}
async fn list_items(Query(pagination): Query<Pagination>) -> String {
let page = pagination.page.unwrap_or(1);
let per_page = pagination.per_page.unwrap_or(20);
format!("Page {page}, {per_page} items")
}
// GET /items?page=2&per_page=50JSON Body
use axum::Json;
use serde::Deserialize;
#[derive(Deserialize)]
struct CreateUser {
name: String,
email: String,
}
async fn create_user(Json(input): Json<CreateUser>) -> String {
format!("Created user: {} ({})", input.name, input.email)
}Form Data
use axum::Form;
use serde::Deserialize;
#[derive(Deserialize)]
struct LoginForm {
username: String,
password: String,
}
async fn login(Form(input): Form<LoginForm>) -> String {
format!("Login attempt: {}", input.username)
}Multiple Extractors in One Handler
You can combine extractors freely. The only rule: at most one extractor can consume the request body (Json, Form, Bytes, etc.), and it must be the last parameter.
use axum::extract::{Path, Query, State, Json};
use std::sync::Arc;
async fn update_item(
State(state): State<Arc<AppState>>, // from app state
Path(id): Path<u64>, // from URL
Query(params): Query<Pagination>, // from query string
Json(body): Json<UpdateItem>, // from body (must be last)
) -> StatusCode {
// ...
StatusCode::OK
}Custom Extractors (No #[async_trait] in 0.8)
Axum 0.8 removed the need for #[async_trait] when implementing extractors, thanks to Rust's native async trait support:
use axum::{
extract::FromRequestParts,
http::{request::Parts, StatusCode},
};
struct CurrentUser {
user_id: u64,
}
impl<S> FromRequestParts<S> for CurrentUser
where
S: Send + Sync,
{
type Rejection = StatusCode;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
// Extract user from headers, cookies, JWT, etc.
let user_id = parts
.headers
.get("x-user-id")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok())
.ok_or(StatusCode::UNAUTHORIZED)?;
Ok(CurrentUser { user_id })
}
}
// Use it like any other extractor
async fn profile(user: CurrentUser) -> String {
format!("User #{}", user.user_id)
}Application State with State
Real applications need shared state: database pools, configuration, caches. Axum's State extractor provides type-safe access to shared data.
Basic State with Arc
use axum::{extract::State, routing::get, Router};
use std::sync::Arc;
struct AppState {
db_pool: PgPool,
config: AppConfig,
}
#[tokio::main]
async fn main() {
let state = Arc::new(AppState {
db_pool: create_pool().await,
config: load_config(),
});
let app = Router::new()
.route("/users", get(list_users))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn list_users(State(state): State<Arc<AppState>>) -> Json<Vec<User>> {
let users = sqlx::query_as!(User, "SELECT * FROM users")
.fetch_all(&state.db_pool)
.await
.unwrap();
Json(users)
}Substates for Access Control
Limit what specific routes can access:
use axum::extract::FromRef;
#[derive(Clone)]
struct AppState {
api_state: ApiState,
admin_state: AdminState,
}
#[derive(Clone)]
struct ApiState {
db_pool: PgPool,
}
#[derive(Clone)]
struct AdminState {
admin_key: String,
}
impl FromRef<AppState> for ApiState {
fn from_ref(app: &AppState) -> Self {
app.api_state.clone()
}
}
impl FromRef<AppState> for AdminState {
fn from_ref(app: &AppState) -> Self {
app.admin_state.clone()
}
}
// This handler can only access ApiState, not AdminState
async fn public_endpoint(State(api): State<ApiState>) -> &'static str {
"Public"
}Error Handling That Scales
Axum's error handling model is straightforward: handlers return Result<T, E> where both T and E implement IntoResponse.
Define a Central Error Type
use axum::{response::{IntoResponse, Response}, http::StatusCode, Json};
enum AppError {
NotFound(String),
BadRequest(String),
Unauthorized,
Internal(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".into()),
AppError::Internal(msg) => {
tracing::error!("Internal error: {msg}");
(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong".into())
}
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}Convert From Library Errors
Implement From to use the ? operator seamlessly:
impl From<sqlx::Error> for AppError {
fn from(err: sqlx::Error) -> Self {
match err {
sqlx::Error::RowNotFound => AppError::NotFound("Resource not found".into()),
_ => AppError::Internal(err.to_string()),
}
}
}
// Now handlers are clean:
async fn get_user(
State(state): State<Arc<AppState>>,
Path(id): Path<i64>,
) -> Result<Json<User>, AppError> {
let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
.fetch_one(&state.db_pool)
.await?; // Automatically converts sqlx::Error -> AppError
Ok(Json(user))
}Middleware with Tower
Axum's superpower is its deep integration with Tower. Every Tower middleware and tower-http layer works out of the box.
Built-in Tower-HTTP Layers
use tower_http::{
cors::CorsLayer,
compression::CompressionLayer,
trace::TraceLayer,
timeout::TimeoutLayer,
limit::RequestBodyLimitLayer,
};
use std::time::Duration;
let app = Router::new()
.route("/", get(index))
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new())
.layer(CorsLayer::permissive())
.layer(TimeoutLayer::new(Duration::from_secs(30)))
.layer(RequestBodyLimitLayer::new(1024 * 1024)); // 1MB limitLayer order matters. Layers wrap from bottom to top. The last
.layer()call is the outermost layer (runs first on request, last on response).
Writing Custom Middleware
use axum::{http::Request, middleware::Next, response::Response};
async fn timing_middleware(req: Request, next: Next) -> Response {
let start = std::time::Instant::now();
let method = req.method().clone();
let uri = req.uri().clone();
let response = next.run(req).await;
let duration = start.elapsed();
tracing::info!("{method} {uri} -> {} in {duration:?}", response.status());
response
}
// Apply it:
let app = Router::new()
.route("/", get(index))
.layer(axum::middleware::from_fn(timing_middleware));Middleware with State
use axum::extract::State;
async fn auth_middleware(
State(state): State<Arc<AppState>>,
req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let token = req
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.ok_or(StatusCode::UNAUTHORIZED)?;
// Validate token against your state
if !validate_token(&state, token).await {
return Err(StatusCode::UNAUTHORIZED);
}
Ok(next.run(req).await)
}
let app = Router::new()
.route("/protected", get(protected_handler))
.layer(axum::middleware::from_fn_with_state(state.clone(), auth_middleware))
.with_state(state);Applying Middleware to Specific Routes
Use route_layer to apply middleware only to routes defined above it:
let app = Router::new()
.route("/admin", get(admin_panel))
.route_layer(axum::middleware::from_fn(require_admin)) // only /admin
.route("/public", get(public_page)); // no middlewareDatabase Integration with SQLx
Here's how to wire up PostgreSQL with SQLx and Axum for a complete CRUD API.
Dependencies
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "macros"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"Setting Up the Connection Pool
use sqlx::postgres::PgPoolOptions;
#[tokio::main]
async fn main() {
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://user:pass@localhost/mydb".into());
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.expect("Failed to connect to database");
// Run migrations
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("Failed to run migrations");
let state = Arc::new(AppState { db: pool });
let app = Router::new()
.route("/todos", get(list_todos).post(create_todo))
.route("/todos/{id}", get(get_todo).put(update_todo).delete(delete_todo))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}CRUD Handlers
use axum::extract::{Path, State, Json};
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Serialize, FromRow)]
struct Todo {
id: Uuid,
title: String,
completed: bool,
}
#[derive(Debug, Deserialize)]
struct CreateTodo {
title: String,
}
#[derive(Debug, Deserialize)]
struct UpdateTodo {
title: Option<String>,
completed: Option<bool>,
}
async fn list_todos(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<Todo>>, AppError> {
let todos = sqlx::query_as!(Todo, "SELECT id, title, completed FROM todos")
.fetch_all(&state.db)
.await?;
Ok(Json(todos))
}
async fn create_todo(
State(state): State<Arc<AppState>>,
Json(input): Json<CreateTodo>,
) -> Result<(StatusCode, Json<Todo>), AppError> {
let todo = sqlx::query_as!(
Todo,
"INSERT INTO todos (id, title, completed) VALUES ($1, $2, false) RETURNING *",
Uuid::new_v4(),
input.title,
)
.fetch_one(&state.db)
.await?;
Ok((StatusCode::CREATED, Json(todo)))
}
async fn get_todo(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<Todo>, AppError> {
let todo = sqlx::query_as!(Todo, "SELECT * FROM todos WHERE id = $1", id)
.fetch_one(&state.db)
.await?;
Ok(Json(todo))
}
async fn update_todo(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(input): Json<UpdateTodo>,
) -> Result<Json<Todo>, AppError> {
let todo = sqlx::query_as!(
Todo,
r#"UPDATE todos
SET title = COALESCE($2, title),
completed = COALESCE($3, completed)
WHERE id = $1
RETURNING *"#,
id,
input.title,
input.completed,
)
.fetch_one(&state.db)
.await?;
Ok(Json(todo))
}
async fn delete_todo(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
sqlx::query!("DELETE FROM todos WHERE id = $1", id)
.execute(&state.db)
.await?;
Ok(StatusCode::NO_CONTENT)
}Authentication and Authorization
The recommended pattern in Axum is to use a custom extractor for authentication, not middleware. This keeps your handlers clean and makes auth testable.
JWT Auth Extractor
use axum::{
extract::FromRequestParts,
http::{request::Parts, StatusCode},
};
use jsonwebtoken::{decode, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String,
exp: usize,
}
struct AuthUser {
user_id: String,
}
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = StatusCode;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let auth_header = parts
.headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(StatusCode::UNAUTHORIZED)?;
// In production, load the secret from an environment variable
let token_data = decode::<Claims>(
auth_header,
&DecodingKey::from_secret(b"your-secret-key"),
&Validation::default(),
)
.map_err(|_| StatusCode::UNAUTHORIZED)?;
Ok(AuthUser {
user_id: token_data.claims.sub,
})
}
}
// Usage - auth is enforced by the extractor's presence:
async fn my_profile(user: AuthUser) -> String {
format!("Hello, user {}", user.user_id)
}
// No auth needed - just don't include the extractor:
async fn public_page() -> &'static str {
"Anyone can see this"
}Optional Authentication with Option<T>
Wrap any extractor in Option<T> to make it optional. If extraction fails for any reason, the handler receives None instead of a rejection:
async fn feed(user: Option<AuthUser>) -> String {
match user {
Some(u) => format!("Personalized feed for {}", u.user_id),
None => "Public feed".into(),
}
}Note:
Option<AuthUser>returnsNonefor all extraction failures - whether the token is missing or invalid. If you need to distinguish between "no credentials" and "bad credentials," handle that logic inside a custom extractor that returnsOk(None)for missing credentials andErr(...)for invalid ones.
Serving Static Files and SPAs
Use tower-http to serve static files alongside your API:
cargo add tower-http -F fsStatic File Server
use tower_http::services::ServeDir;
let app = Router::new()
.nest("/api", api_routes())
.nest_service("/", ServeDir::new("static"));Single-Page Application (React, Vue, etc.)
For SPAs, you need a fallback to index.html for client-side routing:
use tower_http::services::{ServeDir, ServeFile};
let app = Router::new()
.nest("/api", api_routes())
.nest_service(
"/",
ServeDir::new("dist").not_found_service(ServeFile::new("dist/index.html")),
);WebSockets
Axum has first-class WebSocket support:
use axum::{
extract::ws::{WebSocket, WebSocketUpgrade},
response::IntoResponse,
};
async fn ws_handler(ws: WebSocketUpgrade) -> impl IntoResponse {
ws.on_upgrade(handle_socket)
}
async fn handle_socket(mut socket: WebSocket) {
while let Some(Ok(msg)) = socket.recv().await {
// Echo messages back
if socket.send(msg).await.is_err() {
break;
}
}
}
// Route:
// .route("/ws", get(ws_handler))Tip: Enable the
wsfeature on axum:cargo add axum -F ws
Testing Without a Running Server
Axum routes are Tower services, so you can test them directly with oneshot - no HTTP server needed:
#[cfg(test)]
mod tests {
use super::*;
use axum::{body::Body, http::{Request, StatusCode}};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[tokio::test]
async fn test_health_check() {
let state = Arc::new(AppState { todos: RwLock::new(Vec::new()) });
let app = app(state);
let response = app
.oneshot(
Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&body[..], b"OK");
}
#[tokio::test]
async fn test_create_and_list_todos() {
let state = Arc::new(AppState { todos: RwLock::new(Vec::new()) });
// Create a todo
let response = app(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/todos")
.header("content-type", "application/json")
.body(Body::from(r#"{"title":"Test todo"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
// List todos
let response = app(state)
.oneshot(
Request::builder()
.uri("/todos")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let todos: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
assert_eq!(todos.len(), 1);
assert_eq!(todos[0]["title"], "Test todo");
}
}Add to [dev-dependencies]:
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"Project Structure for Large Applications
As your API grows, organize by feature:
src/
โโโ main.rs # Server setup
โโโ lib.rs # Shared types
โโโ config.rs # Configuration
โโโ error.rs # AppError enum
โโโ state.rs # AppState
โโโ routes/
โ โโโ mod.rs # Route modules
โ โโโ users.rs # User handlers
โ โโโ todos.rs # Todo handlers
โ โโโ auth.rs # Auth handlers
โโโ extractors/
โ โโโ mod.rs
โ โโโ auth.rs # AuthUser
โโโ middleware/
โ โโโ mod.rs
โ โโโ timing.rs # Request timing
โ โโโ logging.rs # Custom logging
โโโ models/
โโโ mod.rs
โโโ user.rs # User + DB queries
โโโ todo.rs # Todo + DB queries
migrations/
โโโ 001_create_users.sql
โโโ 002_create_todos.sqlEach route module exports a function that returns a Router:
// src/routes/users.rs
use axum::{routing::get, Router};
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/users", get(list).post(create))
.route("/users/{id}", get(show).put(update).delete(destroy))
}
// src/routes/mod.rs
pub fn all_routes() -> Router<Arc<AppState>> {
Router::new()
.merge(users::router())
.merge(todos::router())
.merge(auth::router())
}
// src/main.rs
let app = routes::all_routes()
.layer(TraceLayer::new_for_http())
.with_state(state);Deployment
Standard Deployment (Docker)
# Build stage
FROM rust:1.85 AS builder
WORKDIR /app
COPY . .
RUN cargo build --release
# Runtime stage
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/axum-api /usr/local/bin/
EXPOSE 3000
CMD ["axum-api"]Graceful Shutdown
Production servers need graceful shutdown:
use tokio::signal;
#[tokio::main]
async fn main() {
// ... build your app ...
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.unwrap();
}
async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c().await.expect("Failed to listen for Ctrl+C");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("Failed to listen for SIGTERM")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("Shutting down gracefully...");
}Axum vs Actix Web vs Rocket
Here's an honest comparison as of 2026:
| Axum 0.8 | Actix Web 4 | Rocket 0.5 | |
|---|---|---|---|
| Performance | Excellent | Slightly faster in benchmarks | Slower under load |
| Memory | Best (standby + load) | Good under load, higher standby | Higher under load |
| Middleware | Tower ecosystem (huge) | Own system (mature) | Fairings (built-in) |
| Macros | None required | None required | Attribute macros |
| Async | Native Tokio | Tokio-based | Tokio-based |
| Learning curve | Moderate | Moderate | Low (batteries included) |
| Ecosystem | Growing fast | Mature | Smaller |
| Maintained by | Tokio team | Community | Sergio Benitez |
Choose Axum for: Most applications, Tower ecosystem compatibility, type-safe design, and Tokio integration.
Choose Actix Web for: Maximum raw throughput where every microsecond matters (financial systems, real-time analytics).
Choose Rocket for: Rapid prototyping, smaller projects, developer experience priority.
For new projects in 2026, Axum is the default choice for the Rust ecosystem. Its growth trajectory, team backing, and middleware compatibility make it the safest long-term bet.
FAQ
What version of Axum should I use?
Use Axum 0.8.x - the current stable release with the new {param} path syntax, native async traits, and improved error messages.
Do I need #[async_trait] for custom extractors?
No. Axum 0.8 uses Rust's native async traits. Remove #[async_trait] from all FromRequest and FromRequestParts implementations.
How do I handle CORS?
Use tower-http:
use tower_http::cors::CorsLayer;
let app = Router::new()
.route("/api/data", get(handler))
.layer(CorsLayer::permissive()); // or configure specific originsCan I use Axum with GraphQL?
Yes. The async-graphql crate has excellent Axum integration via async-graphql-axum.
How do I add request validation?
Use the validator crate with a custom extractor, or use axum-extra's WithRejection for better error messages on extraction failures.
Is Axum production-ready?
Yes. Axum is stable, widely used in production by companies of all sizes, and backed by the Tokio team. Discord, for example, uses Tokio and Tower extensively in their Rust services.
How do I generate OpenAPI docs?
Use the utoipa crate. It provides derive macros that generate OpenAPI specs from your Axum types and handlers.
Complete Working Example
All the code in this article is based on a working project. Here's the full Cargo.toml and main.rs for a Todo CRUD API that you can run right now:
Cargo.toml:
[package]
name = "axum-api"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower-http = { version = "0.6", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4", "serde"] }src/main.rs:
use axum::{
extract::{Json, Path, Query, State},
http::StatusCode,
middleware,
response::{IntoResponse, Response},
routing::get,
Router,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, RwLock};
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Todo {
id: Uuid,
title: String,
completed: bool,
}
#[derive(Debug, Deserialize)]
struct CreateTodo { title: String }
#[derive(Debug, Deserialize)]
struct UpdateTodo { title: Option<String>, completed: Option<bool> }
#[derive(Debug, Deserialize)]
struct TodoQuery { completed: Option<bool> }
struct AppState { todos: RwLock<Vec<Todo>> }
enum ApiError { NotFound(String) }
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
match self {
ApiError::NotFound(msg) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": msg })),
).into_response(),
}
}
}
async fn log_request(
req: axum::http::Request<axum::body::Body>,
next: middleware::Next,
) -> Response {
let method = req.method().clone();
let uri = req.uri().clone();
tracing::info!("--> {method} {uri}");
let response = next.run(req).await;
tracing::info!("<-- {} {method} {uri}", response.status());
response
}
async fn health() -> &'static str { "OK" }
async fn list_todos(
State(state): State<Arc<AppState>>,
Query(query): Query<TodoQuery>,
) -> Json<Vec<Todo>> {
let todos = state.todos.read().unwrap();
let filtered: Vec<Todo> = todos
.iter()
.filter(|t| query.completed.map_or(true, |c| t.completed == c))
.cloned()
.collect();
Json(filtered)
}
async fn get_todo(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<Todo>, ApiError> {
let todos = state.todos.read().unwrap();
todos.iter().find(|t| t.id == id).cloned().map(Json)
.ok_or_else(|| ApiError::NotFound(format!("Todo {id} not found")))
}
async fn create_todo(
State(state): State<Arc<AppState>>,
Json(input): Json<CreateTodo>,
) -> (StatusCode, Json<Todo>) {
let todo = Todo { id: Uuid::new_v4(), title: input.title, completed: false };
state.todos.write().unwrap().push(todo.clone());
(StatusCode::CREATED, Json(todo))
}
async fn update_todo(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(input): Json<UpdateTodo>,
) -> Result<Json<Todo>, ApiError> {
let mut todos = state.todos.write().unwrap();
let todo = todos.iter_mut().find(|t| t.id == id)
.ok_or_else(|| ApiError::NotFound(format!("Todo {id} not found")))?;
if let Some(title) = input.title { todo.title = title; }
if let Some(completed) = input.completed { todo.completed = completed; }
Ok(Json(todo.clone()))
}
async fn delete_todo(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
let mut todos = state.todos.write().unwrap();
let len = todos.len();
todos.retain(|t| t.id != id);
if todos.len() == len {
return Err(ApiError::NotFound(format!("Todo {id} not found")));
}
Ok(StatusCode::NO_CONTENT)
}
fn app(state: Arc<AppState>) -> Router {
Router::new()
.route("/health", get(health))
.route("/todos", get(list_todos).post(create_todo))
.route("/todos/{id}", get(get_todo).put(update_todo).delete(delete_todo))
.layer(middleware::from_fn(log_request))
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())
.with_state(state)
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "axum_api=debug,tower_http=debug".parse().unwrap()),
)
.init();
let state = Arc::new(AppState { todos: RwLock::new(Vec::new()) });
let listener = tokio::net::TcpListener::bind("0.0.0.0:3030").await.unwrap();
tracing::info!("Listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app(state)).await.unwrap();
}Test it:
cargo run
# Health check
curl http://localhost:3030/health
# => OK
# Create a todo
curl -X POST http://localhost:3030/todos \
-H "Content-Type: application/json" \
-d '{"title":"Learn Axum"}'
# => {"id":"...","title":"Learn Axum","completed":false}
# List todos
curl http://localhost:3030/todos
# => [{"id":"...","title":"Learn Axum","completed":false}]
# Update a todo (replace UUID)
curl -X PUT http://localhost:3030/todos/<uuid> \
-H "Content-Type: application/json" \
-d '{"completed":true}'
# Filter by completed
curl "http://localhost:3030/todos?completed=true"
# Delete a todo
curl -X DELETE http://localhost:3030/todos/<uuid>
# => 204 No ContentWhat's Next?
You now have everything you need to build production Rust APIs with Axum. Here are some next steps:
- Add a database - Integrate SQLx or Diesel with the patterns shown above.
- Add authentication - Build the JWT extractor from the auth section.
- Generate OpenAPI docs - Use
utoipato auto-generate Swagger documentation. - Practice - Try the Rust challenges on Rustfinity to sharpen your skills.
Axum's combination of type safety, Tower compatibility, and zero-macro API design makes it the best choice for Rust web development in 2026. Start building.