In the evolving landscape of 2025, Rust has firmly established itself not just as a systems language, but as a premier choice for backend web development. While the ecosystem used to be fragmented, Axum has emerged as the clear standard for many developers.
Why? Because it is ergonomic, modular, and built directly on top of Tokio (the industry-standard async runtime) and Tower (a robust service abstraction). If you are looking to build a microservice or a monolith that can handle thousands of requests per second with the type safety Rust is famous for, you are in the right place.
In this guide, we aren’t just going to print “Hello World.” We are going to build a functional Task Management API with CRUD operations, shared state concurrency, and proper error handling.
Prerequisites #
Before we dive into the code, ensure your environment is ready. We are targeting intermediate Rust developers, so we assume you are comfortable with the borrow checker and basic syntax.
- Rust Toolchain: Ensure you are running the latest stable version (Rust 1.83+ recommended).
rustc --version - Package Manager: We will use
cargo. - IDE: VS Code with
rust-analyzeror JetBrains RustRover is highly recommended for autocomplete functionality, especially with Axum’s sophisticated type system. - HTTP Client:
curlor a tool like Postman/Insomnia to test our endpoints.
Why Axum? A Quick Comparison #
If you come from a Python (FastAPI) or Node.js (Express) background, Axum will feel surprisingly familiar, yet significantly faster. But how does it stack up against other Rust frameworks?
| Feature | Axum | Actix-web | Rocket |
|---|---|---|---|
| Async Runtime | Tokio (Native) | Tokio (Custom Wrapper) | Tokio (Abstracted) |
| Ergonomics | High (Macro-free routing) | Medium (Actor model legacy) | High (Heavy macro usage) |
| Middleware | Uses tower ecosystem |
Custom middleware system | Custom Fairings |
| Compile Time | Fast | Moderate | Slow (due to macros) |
| Community Status | De Facto Standard | Mature / Stable | Stable / Less Active |
Axum’s greatest strength is its implementation of the tower::Service trait, meaning standard middleware for timeouts, tracing, and compression work out of the box.
The Architecture of Our API #
Before writing code, let’s visualize how a request flows through an Axum application. Understanding the concept of Extractors is key to mastering this framework.
Step 1: Project Setup and Dependencies #
Let’s initialize a new binary project.
cargo new axum_task_api
cd axum_task_apiWe need to add a few heavyweight crates to our Cargo.toml.
- axum: The web framework.
- tokio: The async runtime (we need the
fullfeature). - serde: For serializing/deserializing JSON.
- tracing / tracing-subscriber: For logging (essential in production).
Open Cargo.toml and add the following:
[package]
name = "axum_task_api"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4", "serde"] }Step 2: Defining the Application State #
In a real-world scenario, you would connect to a database like PostgreSQL. For this tutorial, we will use an in-memory HashMap to store our tasks. However, because Axum handles requests asynchronously across multiple threads, we cannot use a mutable global variable.
We must use thread-safe constructs: Arc (Atomic Reference Counting) and RwLock (Read/Write Lock).
Create a file named src/model.rs:
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
pub id: Uuid,
pub title: String,
pub completed: bool,
}
// Data Transfer Object (DTO) for creating a task
#[derive(Debug, Deserialize)]
pub struct CreateTaskRequest {
pub title: String,
}
// DTO for updating a task
#[derive(Debug, Deserialize)]
pub struct UpdateTaskRequest {
pub title: Option<String>,
pub completed: Option<bool>,
}Now, let’s define our state in src/main.rs. We’ll create a type alias to make the signature cleaner.
Step 3: Implementing the Handlers #
This is where the magic happens. In Axum, handlers are just async functions that take arguments. These arguments are called Extractors. Axum looks at the function signature and automatically extracts data from the HTTP request (like the Body, Path parameters, or State).
Let’s modify src/main.rs to include our imports, state, and the first handler.
use axum::{
extract::{Path, State},
http::StatusCode,
routing::{get, patch},
Json, Router,
};
use serde_json::{json, Value};
use std::{
collections::HashMap,
sync::{Arc, RwLock},
};
use uuid::Uuid;
mod model;
use model::{CreateTaskRequest, Task, UpdateTaskRequest};
// 1. Define the Application State
type Db = Arc<RwLock<HashMap<Uuid, Task>>>;
#[tokio::main]
async fn main() {
// Initialize tracing (logging)
tracing_subscriber::fmt::init();
// Initialize state
let db = Db::default();
// 2. Build our application with routes
let app = Router::new()
.route("/tasks", get(get_tasks).post(create_task))
.route("/tasks/:id", get(get_task).patch(update_task).delete(delete_task))
.with_state(db);
// 3. Run the server
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("Server running on http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}The Create Handler (POST) #
Notice how we use State<Db> to access our database and Json<CreateTaskRequest> to parse the body automatically.
async fn create_task(
State(db): State<Db>,
Json(payload): Json<CreateTaskRequest>,
) -> (StatusCode, Json<Task>) {
let mut tasks = db.write().unwrap(); // Acquire write lock
let task = Task {
id: Uuid::new_v4(),
title: payload.title,
completed: false,
};
tasks.insert(task.id, task.clone());
(StatusCode::CREATED, Json(task))
}The Read Handlers (GET) #
Here we read all tasks, or a specific task by ID using the Path extractor.
async fn get_tasks(State(db): State<Db>) -> Json<Vec<Task>> {
let tasks = db.read().unwrap(); // Acquire read lock
let tasks_vec: Vec<Task> = tasks.values().cloned().collect();
Json(tasks_vec)
}
async fn get_task(
Path(id): Path<Uuid>,
State(db): State<Db>,
) -> Result<Json<Task>, StatusCode> {
let tasks = db.read().unwrap();
if let Some(task) = tasks.get(&id) {
Ok(Json(task.clone()))
} else {
Err(StatusCode::NOT_FOUND)
}
}The Update Handler (PATCH) #
async fn update_task(
Path(id): Path<Uuid>,
State(db): State<Db>,
Json(payload): Json<UpdateTaskRequest>,
) -> Result<Json<Task>, StatusCode> {
let mut tasks = db.write().unwrap();
if let Some(task) = tasks.get_mut(&id) {
if let Some(title) = payload.title {
task.title: title;
}
if let Some(completed) = payload.completed {
task.completed = completed;
}
Ok(Json(task.clone()))
} else {
Err(StatusCode::NOT_FOUND)
}
}The Delete Handler (DELETE) #
async fn delete_task(
Path(id): Path<Uuid>,
State(db): State<Db>,
) -> StatusCode {
let mut tasks = db.write().unwrap();
if tasks.remove(&id).is_some() {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}Step 4: Testing Your API #
With the code written, it’s time to fire it up.
- Run the application:
cargo run - Open a new terminal to act as the client.
Create a Task:
curl -X POST http://127.0.0.1:3000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Learn Axum"}'Response: {"id":"...", "title":"Learn Axum", "completed":false}
List Tasks:
curl http://127.0.0.1:3000/tasksAdvanced: Best Practices for Production #
While the code above works perfectly for a demo, here are three critical adjustments needed for a production-grade Axum application.
1. Proper Error Handling #
Returning StatusCode directly is quick, but returning structured JSON errors is better for frontend clients. Implement axum::response::IntoResponse for a custom Error enum. This allows you to centralize error logic (mapping DB errors to 500s, input errors to 400s).
2. Layering and Middleware #
Never expose an API without logging and timeouts. Use tower_http.
// Add to Cargo.toml: tower-http = { version = "0.5", features = ["trace"] }
use tower_http::trace::TraceLayer;
// In main():
let app = Router::new()
// ... routes
.layer(TraceLayer::new_for_http());3. State Management #
In our example, we used std::sync::RwLock. If you are holding locks across .await points (e.g., querying a real database), you must not use the standard library lock, as it will freeze the Tokio executor. Instead, rely on the database pool’s internal management (like sqlx::Pool) or use tokio::sync::RwLock.
Common Pitfalls #
- Blocking the Thread: Never perform heavy computation (cryptography, image processing) directly in an async handler. It blocks the runtime. Use
tokio::task::spawn_blockingfor CPU-intensive tasks. - Extractor Order: Axum extractors consume the request body. If you try to extract
Json<Body>twice, or extract it before logging middleware has processed it, you may run into stream consumption issues. - Cloning State: The
Stateextractor clones theArcpointer, which is cheap. Do not try to pass deep references; just let theArcdo its job.
Conclusion #
You have successfully built a high-performance, asynchronous REST API using Rust and Axum. We covered routing, JSON extraction, shared state concurrency, and HTTP methods.
Axum represents the maturity of the Rust web ecosystem. It gives you the raw performance of non-blocking I/O with the developer experience of a high-level framework.
What’s next?
- Replace the
HashMapwith a persistent database using SQLx. - Add authentication using the
axum-extracrate. - Deploy your binary to a Docker container.
Rust is challenging, but with tools like Axum, the barrier to entry for web development is lower than ever. Happy coding