- Introduction — Why Web APIs in Rust?
- First Steps in Axum — Handlers and Routing
- Extractors — Pulling What You Want Out of a Request
- Shared State — Sharing a Database Pool and Config
- tower Middleware — Layering Cross-Cutting Concerns
- JSON with serde — Serialization and Deserialization
- Database Access with sqlx — Queries Verified at Compile Time
- Error Handling — Turning a Result into a Response
- A Comparison with Actix Web — Which to Choose
- Wrapping Up
- References
Introduction — Why Web APIs in Rust?
Web backends once seemed far removed from Rust. Concepts like ownership and lifetimes felt like a barrier to entry for request-response code. But things have changed. With mature web frameworks built on the async/await and Tokio we saw in earlier posts, building web APIs in Rust has become surprisingly clean.
The appeal of a Rust web backend is clear: a vast class of bugs caught at compile time, predictable low latency without a garbage collector, and the robustness the type system enforces. Misspell a JSON field name and the compiler rejects it; Option forces you to handle nulls.
This post centers on Axum, currently the most popular framework, and walks through the practical flow from routing to database access to error handling. At the end we compare it with the other major player, Actix Web. Axum is a good starting point because it was built by the Tokio team and meshes smoothly with the tower ecosystem.
First Steps in Axum — Handlers and Routing
The two most fundamental concepts in Axum are handlers and routers. A handler is an async function that takes a request and returns a response; a router defines which path and method go to which handler.
use axum::{routing::get, Router};
#[tokio::main]
async fn main() {
// router: wire paths to handlers
let app = Router::new()
.route("/", get(root))
.route("/health", get(health));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
// handler: just an async function
async fn root() -> &'static str {
"Hello, Axum!"
}
async fn health() -> &'static str {
"OK"
}
A big advantage of Axum is that a handler is just an async function. There's no macro to wrap it in, no special trait to implement — you take whatever you want as arguments and return something that can be a response. A return type of &'static str becomes an HTTP response automatically because Axum uses a trait called IntoResponse to turn many types into responses: strings, status codes, JSON, tuples, and more all become responses directly.
Extractors — Pulling What You Want Out of a Request
Axum's real elegance is in extractors. Write a particular type as a handler argument, and Axum pulls that piece out of the request and hands it to you. Path parameters, query strings, JSON bodies, headers — most request elements have an extractor.
use axum::{
extract::{Path, Query, Json},
routing::{get, post},
Router,
};
use serde::Deserialize;
#[derive(Deserialize)]
struct Pagination {
page: u32,
per_page: u32,
}
#[derive(Deserialize)]
struct CreateUser {
name: String,
email: String,
}
// path parameter: /users/42 -> id = 42
async fn get_user(Path(id): Path<u64>) -> String {
format!("user {id}")
}
// query string: /users?page=2&per_page=20
async fn list_users(Query(p): Query<Pagination>) -> String {
format!("page {} ({} per page)", p.page, p.per_page)
}
// JSON body
async fn create_user(Json(payload): Json<CreateUser>) -> String {
format!("created {} <{}>", payload.name, payload.email)
}
fn app() -> Router {
Router::new()
.route("/users/{id}", get(get_user))
.route("/users", get(list_users).post(create_user))
}
There's a rule to extractor ordering. An extractor that consumes the request body (like Json) reads the whole body, so there can be only one, and it must come last in the argument list. Extractors that don't consume the body — headers, paths — can appear multiple times before it. If extraction fails (say, JSON parsing fails), Axum automatically returns an appropriate 4xx response, so your handler body can focus only on the success path.
Shared State — Sharing a Database Pool and Config
A real application's handlers need access to shared resources like a database connection pool or config values. Axum handles this as state, injected into handlers through the State extractor.
use axum::{extract::State, routing::get, Router};
use std::sync::Arc;
// state shared across the whole application
#[derive(Clone)]
struct AppState {
// in reality this holds a sqlx pool, config, etc.
app_name: Arc<String>,
}
async fn handler(State(state): State<AppState>) -> String {
format!("running: {}", state.app_name)
}
#[tokio::main]
async fn main() {
let state = AppState {
app_name: Arc::new("my-service".to_string()),
};
let app = Router::new()
.route("/", get(handler))
.with_state(state); // attach state to the router
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
The reason AppState must be Clone is that the state's value is cloned per request. So the convention is to wrap real resources, like a database pool, in an Arc so they clone cheaply. The Arc from the earlier smart-pointers post is used naturally here: only one pool actually exists, and each request shares a clone of that Arc.
tower Middleware — Layering Cross-Cutting Concerns
Logic applied in common across many routes — logging, authentication, timeouts, compression, CORS — is handled as middleware. Rather than inventing a fresh middleware system, Axum uses tower, the standard service abstraction of the Rust ecosystem, directly. That lets you layer on the vast collection of middleware from tower and tower-http right away.
use axum::{routing::get, Router};
use tower_http::trace::TraceLayer;
use tower_http::timeout::TimeoutLayer;
use std::time::Duration;
fn app() -> Router {
Router::new()
.route("/", get(|| async { "Hello" }))
// middleware is applied with layer
.layer(TraceLayer::new_for_http()) // request logging/tracing
.layer(TimeoutLayer::new(Duration::from_secs(10))) // request timeout
}
Applying middleware with layer applies it to all routes beneath it. Stack several layers and requests pass from the outside in, while responses flow back out the other way. Thanks to this shared-tower design, a big strength of Axum is that you can reuse middleware with other tower-based tools, such as the gRPC framework tonic.
JSON with serde — Serialization and Deserialization
The core of a web API is exchanging JSON. In Rust this is essentially handled by the serde crate. Derive Serialize/Deserialize on a struct and the compiler generates the conversion between that type and JSON.
use axum::Json;
use serde::{Deserialize, Serialize};
// type for the incoming request body
#[derive(Deserialize)]
struct CreateTodo {
title: String,
#[serde(default)] // defaults to false if absent
done: bool,
}
// type to send in the response
#[derive(Serialize)]
struct Todo {
id: u64,
title: String,
done: bool,
}
async fn create_todo(Json(input): Json<CreateTodo>) -> Json<Todo> {
let todo = Todo {
id: 1,
title: input.title,
done: input.done,
};
Json(todo) // wrapping in Json makes it a JSON response automatically
}
The Json extractor takes the request body as a type, and wrapping the return in Json sends the response as JSON. serde's attributes let you control the details: #[serde(rename_all = "camelCase")] changes the field-naming convention, #[serde(default)] supplies a default for a missing field, and an Option<T> field expresses a value that may or may not be present. All of this attaches declaratively to the type, so the JSON contract is right there in the code.
Database Access with sqlx — Queries Verified at Compile Time
To store and retrieve data you need a database. A widely used choice in the async Rust ecosystem is sqlx. What's distinctive about sqlx is that it's not an ORM — you write raw SQL, but it checks that SQL against your real database schema at compile time. A misspelled column name or a type mismatch is caught at compile time, not at run time.
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
#[derive(sqlx::FromRow)]
struct User {
id: i64,
name: String,
email: String,
}
async fn setup_pool() -> PgPool {
PgPoolOptions::new()
.max_connections(5)
.connect("postgres://localhost/mydb")
.await
.unwrap()
}
async fn find_user(pool: &PgPool, id: i64) -> Result<Option<User>, sqlx::Error> {
// query_as maps the result into a struct
let user = sqlx::query_as::<_, User>(
"SELECT id, name, email FROM users WHERE id = $1",
)
.bind(id)
.fetch_optional(pool)
.await?;
Ok(user)
}
sqlx provides a connection pool, which you share into the application via the State we saw earlier. Parameters are passed through numbered placeholders (a dollar sign followed by a position number) together with bind, which shuts the door on SQL injection entirely. Result rows are mapped into a struct that derives FromRow. fetch_optional expresses "there may be no result" as an Option; use fetch_all for many rows, and fetch_one when you expect exactly one. Going further, the query! macro offers a powerful mode that connects to the real DB at compile time and verifies the query itself.
Error Handling — Turning a Result into a Response
Most of the examples so far dealt only with the success path, but a real API must handle failure gracefully. The Rust idiom is for a handler to return a Result and have the error type convert itself into an HTTP response. You do that by implementing IntoResponse on the error type.
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
// application-specific error type
enum AppError {
NotFound,
Database(sqlx::Error),
}
// define how the error becomes an HTTP response
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::NotFound => (StatusCode::NOT_FOUND, "not found"),
AppError::Database(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
// auto-convert a sqlx error into AppError (for the ? operator)
impl From<sqlx::Error> for AppError {
fn from(e: sqlx::Error) -> Self {
AppError::Database(e)
}
}
async fn get_user(
State(pool): State<sqlx::PgPool>,
Path(id): Path<i64>,
) -> Result<Json<String>, AppError> {
let user = find_user(&pool, id).await?; // sqlx::Error becomes AppError via ?
match user {
Some(_) => Ok(Json(format!("user {id}"))),
None => Err(AppError::NotFound), // converts to a 404
}
}
The beauty of this pattern is how it combines with the ? operator. Once you implement From<sqlx::Error>, just appending ? after a database call turns a failure into an AppError automatically, which in turn becomes an appropriate HTTP status code. The handler body reads naturally along the success flow, and errors propagate quietly upward into a consistently shaped response. In practice you'd polish this with a crate like thiserror to make it even more concise.
A Comparison with Actix Web — Which to Choose
Beyond Axum, the other major framework is Actix Web. It has been mature for a long time and made its name with top-tier performance across various benchmarks. The two frameworks are in fact quite similar: both run on async/await, use extractor-style handlers, and handle JSON with serde.
The key differences are these:
- Middleware ecosystem: Axum adopts tower directly, sharing middleware from tower/tower-http and with other tower-based tools like tonic. Actix has its own middleware system.
- Runtime: Axum is tied directly to Tokio. Actix has historically run on its own actor runtime (actix-rt, itself Tokio-based).
- Design philosophy: Actix started, as its name suggests, from the actor model, and traces of that remain in its state management. Axum aims at a thinner abstraction of functions and tower services.
- Team and integration: Axum is maintained by the Tokio team, so its integration with Tokio, hyper, and tower is seamless.
For an easy comparison, here's an Actix handler too. You can see the shape isn't very different from Axum.
use actix_web::{get, web, App, HttpServer, Responder};
#[get("/users/{id}")]
async fn get_user(path: web::Path<u64>) -> impl Responder {
let id = path.into_inner();
format!("user {id}")
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| App::new().service(get_user))
.bind(("0.0.0.0", 3000))?
.run()
.await
}
To summarize the decision: if you value integration with the tower ecosystem, closeness to Tokio, and a thin, predictable abstraction, Axum is the natural default today. If you want long-proven maturity, rich features, and top-of-the-charts benchmark numbers, Actix Web is still an excellent choice. That said, the practical performance difference between the two is negligible for most applications, so it's realistic to choose on ecosystem fit and team preference.
Wrapping Up
Building web APIs in Rust is no longer a special ordeal — it's closer to a pleasant experience. To recap the flow this post covered:
- An Axum handler is just an
asyncfunction, and extractors pull what you want from a request declaratively. - Shared resources like a database pool are wrapped in
Arcand injected as state. - Cross-cutting concerns like logging and timeouts are layered on as tower middleware.
- serde handles JSON; sqlx handles SQL verified at compile time.
- Errors convert gracefully into responses via a type implementing
IntoResponseplus the?operator. - Actix Web is a mature, powerful alternative, and the choice often comes down to ecosystem fit rather than performance.
In an environment where the type system enforces the contract and the compiler catches a vast class of bugs up front, your API is robust from day one. The safety and performance Rust offers aren't only a systems-programming story — they hold just as well in a web backend.
References
- Axum official docs (docs.rs): https://docs.rs/axum/latest/axum/
- Axum examples (GitHub): https://github.com/tokio-rs/axum/tree/main/examples
- tower / tower-http: https://docs.rs/tower-http/latest/tower_http/
- serde official site: https://serde.rs/
- sqlx (GitHub): https://github.com/launchbadge/sqlx
- Actix Web official site: https://actix.rs/
현재 단락 (1/217)
Web backends once seemed far removed from Rust. Concepts like ownership and lifetimes felt like a ba...