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 — create
  • GET /users/:id — read
  • GET /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::Database to translate unique-violation into a typed Conflict is 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

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 .