In 2026, Axum 0.8 has settled into the role Express has in Node and FastAPI has in Python: the default. It’s small, type-driven, builds on Tokio + Tower, and doesn’t fight you. If you’re shipping Rust on the network, this is the stack.
This post is the production layout I use. Real error handling, real tracing, real database — not “hello world.”
What we’re building
A small users API:
POST /users— createGET /users/:id— readGET /healthz— readiness probe
With:
- Axum 0.8 routing and extractors
- sqlx with compile-time-checked queries
- Postgres connection pool
- Structured error responses
- Tracing with OTel-compatible spans
- Graceful shutdown
Project setup
cargo new --bin api && cd api
cargo add axum tokio --features axum/macros,tokio/full
cargo add tower tower-http --features "tower-http/trace tower-http/timeout tower-http/cors"
cargo add sqlx --features "runtime-tokio postgres uuid time macros migrate"
cargo add serde --features derive
cargo add serde_json
cargo add tracing tracing-subscriber --features tracing-subscriber/json,tracing-subscriber/env-filter
cargo add thiserror anyhow
cargo add dotenvy
cargo add uuid --features v7
cargo add time --features serde
The dependency list is bigger than Python’s, but every crate here has a single, well-defined job. Axum is the router. Tower is middleware. sqlx is the DB. Tracing is observability. The cargo features select only what you need.
The shape
src/
├── main.rs // bootstrap: tracing, db, router, shutdown
├── config.rs // env config
├── error.rs // AppError, IntoResponse, From impls
├── state.rs // shared AppState
├── db.rs // pool builder + migrations
├── routes/
│ ├── mod.rs
│ ├── health.rs
│ └── users.rs
├── models/
│ └── user.rs
└── lib.rs
Three things separate this from a tutorial: an explicit AppState, a real error type, and a place for migrations.
Config
// src/config.rs
use std::env;
#[derive(Clone)]
pub struct Config {
pub bind: String,
pub database_url: String,
}
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
Ok(Self {
bind: env::var("BIND").unwrap_or_else(|_| "0.0.0.0:8000".into()),
database_url: env::var("DATABASE_URL")?,
})
}
}
Config is plain. Don’t reach for a config crate until you need profiles or layered files.
Errors
// src/error.rs
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("not found: {0}")]
NotFound(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("validation: {0}")]
Validation(String),
#[error(transparent)]
Db(#[from] sqlx::Error),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code) = match &self {
Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
Self::Validation(_) => (StatusCode::BAD_REQUEST, "validation"),
Self::Db(_) | Self::Other(_) => {
tracing::error!(error = %self, "internal error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
}
};
(status, Json(json!({ "error": code, "message": self.to_string() }))).into_response()
}
}
pub type AppResult<T> = Result<T, AppError>;
This is the single most useful pattern in Axum: implement IntoResponse on your error type, and you can ? your way through any handler without writing match everywhere. From<sqlx::Error> is automatic via #[from].
State
// src/state.rs
use sqlx::PgPool;
#[derive(Clone)]
pub struct AppState {
pub db: PgPool,
}
AppState is Clone because Axum clones it per request. PgPool is internally Arc, so cloning is cheap.
Database
// src/db.rs
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
pub async fn make_pool(database_url: &str) -> anyhow::Result<PgPool> {
let pool = PgPoolOptions::new()
.max_connections(20)
.min_connections(2)
.acquire_timeout(std::time::Duration::from_secs(5))
.connect(database_url)
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}
sqlx::migrate! is compile-time — it bundles your migrations/ SQL files into the binary. No ops drama.
Sample migration:
-- migrations/0001_users.sql
CREATE TABLE users (
id UUID PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
full_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Models
// src/models/user.rs
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct User {
pub id: Uuid,
pub email: String,
pub full_name: String,
pub created_at: OffsetDateTime,
}
#[derive(Debug, Deserialize)]
pub struct CreateUser {
pub email: String,
pub full_name: String,
}
sqlx::FromRow lets you query_as!() into your struct directly.
Handlers
// src/routes/users.rs
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::user::{CreateUser, User};
use crate::state::AppState;
pub async fn create_user(
State(state): State<AppState>,
Json(payload): Json<CreateUser>,
) -> AppResult<(StatusCode, Json<User>)> {
if payload.email.trim().is_empty() {
return Err(AppError::Validation("email required".into()));
}
let user = sqlx::query_as!(
User,
r#"
INSERT INTO users (id, email, full_name)
VALUES ($1, $2, $3)
RETURNING id, email, full_name, created_at
"#,
Uuid::now_v7(),
payload.email,
payload.full_name,
)
.fetch_one(&state.db)
.await
.map_err(|e| match e {
sqlx::Error::Database(db) if db.is_unique_violation() => {
AppError::Conflict(format!("email {} taken", payload.email))
}
e => AppError::Db(e),
})?;
Ok((StatusCode::CREATED, Json(user)))
}
pub async fn get_user(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> AppResult<Json<User>> {
let user = sqlx::query_as!(
User,
"SELECT id, email, full_name, created_at FROM users WHERE id = $1",
id,
)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("user {id}")))?;
Ok(Json(user))
}
A few things worth noting:
query_as!is compile-time-checked. It connects to the database at build time, validates the SQL, and types the columns. Wrong column type = compile error. This is unmatched in any other ecosystem.Uuid::now_v7()— UUIDv7 is sortable by creation time. Use it everywhere; UUIDv4 is a relic.- Pattern-matching
sqlx::Error::Databaseto translate unique-violation into a typedConflictis the kind of error refinement Rust makes pleasant.
Wiring it up
// src/routes/mod.rs
use axum::routing::{get, post};
use axum::Router;
use crate::state::AppState;
pub mod health;
pub mod users;
pub fn router(state: AppState) -> Router {
Router::new()
.route("/healthz", get(health::healthz))
.route("/users", post(users::create_user))
.route("/users/{id}", get(users::get_user)) // Axum 0.8 brace syntax
.with_state(state)
}
Axum 0.8 changed :id to {id} for path params — closer to OpenAPI conventions. If you’re upgrading from 0.7, this is the main visible diff.
Health check
// src/routes/health.rs
use axum::extract::State;
use axum::http::StatusCode;
use crate::state::AppState;
pub async fn healthz(State(state): State<AppState>) -> StatusCode {
match sqlx::query("SELECT 1").execute(&state.db).await {
Ok(_) => StatusCode::OK,
Err(_) => StatusCode::SERVICE_UNAVAILABLE,
}
}
A health endpoint that doesn’t ping the dependency it cares about is decoration. Make /healthz mean “I can serve traffic right now.”
Bootstrap
// src/main.rs
use std::time::Duration;
use tower_http::cors::CorsLayer;
use tower_http::timeout::TimeoutLayer;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
use api::{config::Config, db, routes, state::AppState};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn")))
.with(fmt::layer().json())
.init();
let cfg = Config::from_env()?;
let pool = db::make_pool(&cfg.database_url).await?;
let app = routes::router(AppState { db: pool })
.layer(TraceLayer::new_for_http())
.layer(TimeoutLayer::new(Duration::from_secs(15)))
.layer(CorsLayer::permissive());
let listener = tokio::net::TcpListener::bind(&cfg.bind).await?;
tracing::info!(bind = %cfg.bind, "listening");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
async fn shutdown_signal() {
let ctrl_c = async { tokio::signal::ctrl_c().await.ok(); };
#[cfg(unix)]
let term = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("signal").recv().await;
};
#[cfg(not(unix))]
let term = std::future::pending::<()>();
tokio::select! { _ = ctrl_c => {}, _ = term => {} }
tracing::info!("shutting down");
}
The Tower layers are doing the heavy lifting:
TraceLayer— auto request/response spans with timing, status codes, methods.TimeoutLayer— bound the worst case. Defends against hung dependencies.CorsLayer::permissive()— fine for dev; tighten for prod.
Graceful shutdown is critical in Kubernetes. Without it, in-flight requests get killed when the pod terminates.
Testing
// tests/api.rs
use axum::http::StatusCode;
use axum_test::TestServer;
use serde_json::json;
#[tokio::test]
async fn create_and_get_user() -> anyhow::Result<()> {
let pool = api::db::make_pool(&std::env::var("TEST_DATABASE_URL")?).await?;
let app = api::routes::router(api::state::AppState { db: pool });
let server = TestServer::new(app)?;
let resp = server.post("/users")
.json(&json!({ "email": "[email protected]", "full_name": "A B" }))
.await;
resp.assert_status(StatusCode::CREATED);
let id = resp.json::<serde_json::Value>()["id"].as_str().unwrap().to_string();
let got = server.get(&format!("/users/{id}")).await;
got.assert_status_ok();
Ok(())
}
axum-test runs your app in-process. Tests are fast and hermetic. Pair with a Postgres test container or per-test transactions if you want isolation.
Performance & ops notes
- Build a release binary.
cargo build --release. Debug binaries are 10× slower. - Strip symbols for smaller images:
cargo build --release && strip target/release/api. - Multi-stage Docker: build stage compiles, run stage copies binary. Final image: <30 MB on
gcr.io/distroless/cc. - One worker per CPU. Tokio runtime threads default to
num_cpus. Don’t fight it. - Pool size: 20 connections per process is a fine starting point. Watch
pg_stat_activity.
When Rust is and isn’t worth it
I reach for Rust on the backend when:
- Latency budgets are tight (p99 < 50 ms doing real work).
- I’m pinned by Python’s GIL on a CPU-bound path.
- The blast radius of a runtime crash is unacceptable.
- I need predictable memory (long-lived gRPC servers, edge proxies).
I’d stay in Python/Go for:
- The team is one Python person and a deadline.
- The bottleneck is the database, not the app.
- You’re touching a lot of weird ML libraries that only have Python bindings.
Rust pays for itself when it’s the right tool. It punishes you when it isn’t.
Read this next
- FastAPI + Pydantic v2 + SQLAlchemy 2.0 — same problem, Python.
- Go REST API with net/http — same problem, Go.
- The
axumandsqlxrepos — read the examples directories. They’re excellent.
If you want a cargo generate template that gives you all of this — Axum, sqlx, migrations, tracing, Dockerfile — it’s at rajpoot.dev
.
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .