Confession: I migrated a small service from Actix Web to Axum about a year ago because I lost a borrow-checker fight that, in retrospect, was entirely my fault. I want to say the migration was strategic. It wasn’t. It was a tantrum.
Twelve months later I’m still on Axum, and I have opinions worth writing down.
This isn’t an introduction. The Axum docs are fine; you can read them yourself. This is the post I wish I’d had a year ago: the small set of patterns I keep reaching for and the ones I tried and quietly dropped. If you want the broader picture of how Axum stacks up against Actix Web and Rocket, I covered that in my Rust web frameworks comparison. This one goes deeper into a single framework.
The mental model that finally clicked
For about a month I treated Axum like a slightly weird Express. Routes and handlers. Done. That worked, but I kept getting confused whenever the type errors got loud.
What finally clicked was this: Axum is a thin layer over Tower. Every handler is a Service, and middleware is a Layer that wraps it. Routing is composition all the way down. Once I stopped thinking “web framework” and started thinking “Tower with sugar”, everything got easier. The compiler error messages even started looking like they were trying to help me, which was a first.
The practical version: when something feels weird, ask whether the same pattern would feel weird if you wrote it as a plain async function returning a Response. Usually yes. The pain is structural, not Axum’s fault.
Extractors I actually use
Extractors are Axum’s best idea. You declare what your handler needs, and the framework hands it to you. I use about four of them daily and ignore the rest.
use axum::{
extract::{Path, Query, State, Json},
response::IntoResponse,
};
use serde::Deserialize;
use sqlx::PgPool;
#[derive(Deserialize)]
struct ListParams {
q: Option<String>,
limit: Option<i64>,
}
async fn get_user(
State(db): State<PgPool>,
Path(user_id): Path<i64>,
) -> Result<Json<User>, AppError> {
let user = sqlx::query_as!(
User,
"SELECT id, email FROM users WHERE id = $1",
user_id
)
.fetch_one(&db)
.await?;
Ok(Json(user))
}
async fn list_users(
State(db): State<PgPool>,
Query(params): Query<ListParams>,
) -> Result<Json<Vec<User>>, AppError> {
// ...
unimplemented!()
}
The two I most often see misused: Query<T> where T has a field that’s actually optional but isn’t Option<...>, and Json<T> where the struct doesn’t Deserialize cleanly because someone forgot #[serde(rename = "...")]. Both produce error responses that look like Axum is broken when really serde is.
For anything custom (auth tokens, request IDs, tenant context), I write a FromRequestParts impl rather than reading headers in every handler. Once you’ve written one, the next ten take five minutes each.
State and middleware: stop fighting Tower
The State extractor is fine for the database pool and a config struct. For everything else, layers.
This is the layered setup I actually run in production:
use axum::{Router, routing::get};
use tower_http::{
trace::TraceLayer,
timeout::TimeoutLayer,
compression::CompressionLayer,
cors::CorsLayer,
};
use std::time::Duration;
let app = Router::new()
.route("/healthz", get(healthz))
.route("/users/:id", get(get_user))
.route("/users", get(list_users))
.with_state(db_pool)
.layer(TraceLayer::new_for_http())
.layer(TimeoutLayer::new(Duration::from_secs(10)))
.layer(CompressionLayer::new())
.layer(CorsLayer::permissive());
Order matters. Layers wrap the router from the bottom up, which means TraceLayer runs first on the way in and last on the way out. I got this wrong for two weeks: I had a timeout outside my trace layer and spent embarrassingly long wondering why timed-out requests had no spans.
The other thing that took me too long to internalize: route-specific middleware lives on the route, not the global app.
let app = Router::new()
.route(
"/admin/users",
get(admin_list_users)
.layer(axum::middleware::from_fn(require_admin)),
)
.route("/users/:id", get(get_user))
.with_state(db_pool);
If you find yourself reaching for global middleware plus a “skip this path” predicate, that’s the universe telling you to put the layer on the route instead.
Error handling that doesn’t make you cry
This was where I burned the most time. I wanted one error type, one ? in every handler, and meaningful HTTP responses. It took me three rewrites to land on the pattern below.
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("not found")]
NotFound,
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
#[error("validation: {0}")]
Validation(String),
#[error("internal")]
Internal(#[source] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code) = match &self {
AppError::NotFound => (StatusCode::NOT_FOUND, "not_found"),
AppError::Validation(_) => (StatusCode::BAD_REQUEST, "validation"),
AppError::Database(sqlx::Error::RowNotFound) => {
(StatusCode::NOT_FOUND, "not_found")
}
AppError::Database(_) | AppError::Internal(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, "internal")
}
};
if status.is_server_error() {
tracing::error!(error = ?self, "request failed");
}
let body = Json(json!({
"error": { "code": code, "message": self.to_string() }
}));
(status, body).into_response()
}
}
The trick I missed for too long: pattern-match on the variant inside the From<sqlx::Error> path so that RowNotFound becomes a clean 404 instead of a 500. My on-call past self would like a word.
Observability without ceremony
The tracing crate plus tower_http::trace::TraceLayer does about ninety percent of what I need. I add one custom span per request to carry the request ID and user ID, and the rest writes itself.
use tower_http::trace::{DefaultMakeSpan, TraceLayer};
use tracing::Span;
let trace = TraceLayer::new_for_http()
.make_span_with(DefaultMakeSpan::new().include_headers(false))
.on_request(|req: &axum::http::Request<_>, _span: &Span| {
tracing::info!(
method = %req.method(),
path = %req.uri().path(),
"request"
);
});
If you ship to anything OTLP-aware, tracing-opentelemetry plus opentelemetry-otlp gets you working spans in about an hour. I push these to Grafana Tempo from my own services and have stopped reading raw logs for anything other than panics.
I lean on tracing enough that I cover the broader Rust observability story in my sqlx production lessons post, because once you have spans, you want them on database calls too.
Testing handlers without spinning up a server
This is something I push every Rust dev I work with to adopt early: write your handler tests against the router as a Service, not via a live TcpListener. Axum makes this almost suspiciously easy, and once you have it set up the tests run faster than cargo check does on a cold cache.
use axum::{body::Body, http::{Request, StatusCode}, Router};
use tower::ServiceExt;
#[tokio::test]
async fn returns_404_for_missing_user() {
let app: Router = build_app(test_pool()).await;
let response = app
.oneshot(
Request::builder()
.uri("/users/999999")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
tower::ServiceExt::oneshot takes a request and pushes it through the whole router, layers and all. You get to test the real middleware ordering and the real error mapping in one shot. The only thing I usually mock out is anything that calls a third party network service. The database stays real, just pointed at an ephemeral Postgres in CI.
A pattern that saved me time once I started doing it: write a tiny helper that builds the test app from a config struct, then write one test per error variant. If your AppError has NotFound, Validation, Forbidden, and Internal, you want a test that confirms each one returns the right status code and the right JSON shape. They are three-line tests. They catch a surprising number of regressions when somebody adds a new variant and forgets to map it correctly in IntoResponse.
The testing story is the bit of Axum I appreciate most in retrospect. I switched from a framework where I had to spin up an actual HTTP server in tests, with all the port allocation pain that implies, and the difference in feedback speed is the kind of thing that quietly changes how you work.
What I tried and dropped
A short list, since not everything stuck.
axum-extra::TypedHeader for auth. Nice idea, but I wanted my auth extractor to also load the user row, so I went back to a custom FromRequestParts. Less elegant, more useful.
axum-macros::debug_handler. Helpful when you’re learning. After a couple of months you stop needing it, and I forget to add it now.
Global error middleware via HandleErrorLayer. I tried this twice. The flow control got confusing fast. The IntoResponse pattern above is plainer to read.
Putting everything behind Arc<AppState>. Tempting. Mostly unnecessary. State<...> already handles the cloning correctly for things that are cheap to clone, like a PgPool or an HTTP client.
What to do this week
Pick one of these, depending on where your service hurts.
If your handlers are returning Result<Json<T>, (StatusCode, String)>, write a real error enum with IntoResponse. Two hours of work, weeks of cleaner reading.
If you have route-specific auth checks inside handlers, hoist them to a route-level layer. Cleaner code, harder to bypass.
If you don’t have TraceLayer plus a structured logger wired up, do that today. Even local development gets nicer when every request has a span ID.
I keep notes on patterns like these in my Rust and infrastructure work, which is mostly small services that have to stay up. Axum has made keeping them up considerably less stressful than the last framework I tried, and I’ll take that.