Confession: I avoided axum for about a year because I assumed a Rust web framework would make me fight the borrow checker on every request handler. I’d tried an early version, hit a wall of trait-bound errors on a function that returned a string, and quietly went back to writing that service in Go. Then a side project this spring needed a small JSON API that had to sit in front of a Postgres database and not fall over, and I gave axum another shot.
It clicked in an afternoon. Not because I got smarter, but because I finally understood the one idea the docs kind of bury: an axum handler is just an async function, and everything it needs shows up as an argument. Once that landed, the trait errors stopped being mysterious. This post is the version of that afternoon I wish I’d had written down, with the specific things that tripped me up and the code I actually ship now.
Where axum actually fits
I want to be honest about scope, because “should I use Rust for my backend” gets answered with way too much confidence online. Axum is not going to make a CRUD app faster to write than a Laravel or a Next.js route. If your bottleneck is developer time and your traffic is modest, reach for the boring thing.
Where axum earns its place: services where the cost of a runtime crash or a latency spike is high, and where you’re already paying the Rust tax for other reasons. A pricing engine. A websocket fan-out server. An image or token pipeline that has to hold thousands of concurrent connections on one box without a garbage collector deciding to pause at the worst moment. That’s the shape of thing where I stop reaching for Go and start reaching for axum.
Axum sits on top of tokio for the async runtime and tower for middleware, and it’s maintained by the tokio team themselves. That lineage matters: the ecosystem underneath it is the same one half of production Rust already runs on. If you want the longer argument about which async runtime to commit to, I wrote about that in my Rust async runtime comparison, and the short version is that tokio is the safe default and axum inherits it for free.
The handler signature that finally clicked
Here’s the thing nobody told me plainly. In a lot of frameworks you reach into a request object to pull out what you need. In axum you don’t reach for anything. You declare it as a parameter, and axum’s extractor system fills it in before your function body runs.
My first, clumsy attempt looked like I was still writing Express. I grabbed the whole request and dug through it:
// The awkward way I started, fighting the framework
async fn create_user(req: Request) -> Response {
let bytes = to_bytes(req.into_body(), usize::MAX).await.unwrap();
let payload: NewUser = serde_json::from_slice(&bytes).unwrap();
// ...manual status codes, manual serialization, unwrap() everywhere
}
That works, and it’s miserable. Every handler re-parses the body by hand and every unwrap() is a future panic waiting for a malformed request. The axum-native version deletes all of it:
use axum::{extract::Path, Json};
async fn create_user(Json(payload): Json<NewUser>) -> Json<User> {
let user = User::from(payload);
Json(user)
}
async fn get_user(Path(id): Path<i64>) -> Json<User> {
Json(User::find(id))
}
Json<NewUser> means “parse the body as JSON into a NewUser, and if that fails, return a 422 before my code even runs.” Path<i64> pulls the id out of the URL and rejects it if it isn’t an integer. The validation I used to write by hand is now in the type signature. That was the moment axum stopped feeling like a framework I was wrestling and started feeling like one that was doing my chores.
State without the global-variable hangover
Every real service needs shared stuff: a database pool, a config, an HTTP client. My instinct from other languages was to stash it in a global. In Rust that instinct leads straight into a lazy_static or a OnceCell and a low-grade guilt about it.
Axum has a cleaner answer: State. You build your shared state once, hand it to the router, and extract it in any handler that asks for it.
use axum::{extract::State, routing::get, Router};
use sqlx::PgPool;
#[derive(Clone)]
struct AppState {
db: PgPool,
}
#[tokio::main]
async fn main() {
let pool = PgPool::connect(&std::env::var("DATABASE_URL").unwrap())
.await
.unwrap();
let state = AppState { db: pool };
let app = Router::new()
.route("/users/{id}", get(get_user))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn get_user(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Json<User> {
let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
.fetch_one(&state.db)
.await
.unwrap(); // we'll fix this unwrap in a second
Json(user)
}
Notice PgPool is already Clone and cheap to clone, because it’s an Arc under the hood. If your state holds something that isn’t cheap to clone, wrap it in an Arc yourself and clone the Arc, not the data. No global, no unsafe, no singleton pattern. The pool lives for as long as the app and gets handed to each request by reference.
On the database layer specifically, I keep going back and forth between query builders and compile-time-checked queries. I landed on sqlx for most projects, and I laid out why in my sqlx vs diesel breakdown if you’re picking one for your own axum service.
The 0.8 upgrade that broke my routes
Let me save you the twenty minutes I lost. If you’re reading an older tutorial, the route path syntax changed in axum 0.8. It used to be colon-style:
// axum 0.7 and earlier
.route("/users/:id", get(get_user))
In 0.8 it’s curly braces, which lines it up with the OpenAPI and general routing conventions:
// axum 0.8+
.route("/users/{id}", get(get_user))
If you mix them up you get a runtime panic at startup with a message about path segments, not a compile error, which is exactly the kind of thing that makes you doubt your whole setup at 11pm. The colon form isn’t deprecated-with-a-warning; it’s just gone. Check which version your Cargo.toml pulled before you copy any route from a blog post, including this one.
Middleware is the other place I stumbled. Axum leans on tower for this, and tower-http gives you the common pieces so you’re not writing them:
use tower_http::trace::TraceLayer;
use tower_http::cors::CorsLayer;
let app = Router::new()
.route("/users/{id}", get(get_user))
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())
.with_state(state);
Request logging and CORS in four lines, both battle-tested. The mental model that unstuck me: layers wrap the router from the outside in, so the last .layer() you add runs first on the way in. I got the ordering backwards for a debugging session and blamed my own code before I blamed my assumptions.
Errors: giving up on unwrap()
Those unwrap() calls I kept promising to fix. In a real handler, a database miss shouldn’t panic the whole task; it should return a 404. Axum handles this through the IntoResponse trait, which means a handler can return a Result and axum knows how to turn both arms into an HTTP response.
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
async fn get_user(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<User>, StatusCode> {
let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
.fetch_optional(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
match user {
Some(u) => Ok(Json(u)),
None => Err(StatusCode::NOT_FOUND),
}
}
fetch_optional gives me None instead of an error when the row is missing, so a missing user is a clean 404 and a broken connection is a 500. For anything bigger than a toy, I define one app-wide error enum that implements IntoResponse and use ? everywhere, so every handler stays this flat. That’s the pattern that finally made my Rust handlers read as calmly as the Go ones I used to write, without giving up the compiler catching my mistakes. If you want the API-shape side of this, separate from the framework, I keep a running set of REST rules that I hold every backend to.
What I’d actually do this week
If you’ve been axum-curious, don’t read more tutorials. Run cargo new and build exactly one endpoint: a GET /health that returns Json(serde_json::json!({"status": "ok"})). Get it serving on localhost. Then add a POST that takes a Json<T> and echoes it back. That single loop teaches you extractors, responses, and the tokio main function, which is ninety percent of what you’ll use daily.
Once that runs, add State with a real PgPool and the error Result return type from above, and you’ve got the skeleton of every service I ship. The axum docs on docs.rs are genuinely good once you know what an extractor is, so read them second, not first. And if you want to see how I wire these small Rust services into actual projects, that’s the kind of thing I document in my work.
I’m not going to tell you Rust is the right call for your next backend. But if it is, axum is the part I stopped dreading.