A few months back I wrote about picking between axum, actix, and rocket. I picked axum on the next project, then spent a weekend Googling things like “axum sqlx state extractor not working.” The official docs are fine but scattered, and most tutorials online stop at hello-world.
This post is the setup I actually have in production. Not the perfect one. The one that hasn’t woken me up at 3am yet. If you’ve already picked axum and want to wire it up to Postgres without writing your own ORM, here’s what I do.
I’ll show the Cargo.toml, the sqlx wiring, the bit of state plumbing that always trips people up, and the error type that finally made me stop sprinkling .unwrap() everywhere. Real code, the kind I copy-paste into new services.
Why axum, briefly
I’m not going to re-litigate the framework debate. Short version: axum is a tower service, so anything that speaks tower (timeout layers, tracing, auth middleware, rate limiting) plugs in without ceremony. Actix has its own runtime quirks I never warmed to. Rocket is fine but moves slower than I’d like. axum sits on top of tokio and hyper, both of which have years of production miles. Read the axum docs before you write a line of code, especially the page on extractors.
Picking axum was the easy part. The harder part was figuring out the rest of the stack without ending up in dependency hell.
The Cargo.toml I actually start with
Here’s the dependency block I copy-paste into every new axum service. I trimmed the project-specific bits.
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "macros", "migrate"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["trace", "cors", "timeout"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
thiserror = "2"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
Two things worth calling out. The tokio = "full" flag is lazy. It’s what I do at the start, then trim once I know which features I actually need. The sqlx macros feature is the one I care about most. It’s what gives you compile-time checked SQL queries against your real database schema. More on that in a second.
A note on anyhow plus thiserror. They are not the same thing and they are not interchangeable. anyhow is for application code where you just want the error to bubble. thiserror is for library boundaries and your API error type. Use both. They cost almost nothing.
sqlx is the actual reason I picked this stack
I’ve tried diesel. I’ve tried seaorm. Both are fine. The thing that made me stick with sqlx is that it doesn’t make me learn a query DSL. I write SQL. The macro reads the SQL at compile time, connects to a dev Postgres, and tells me if I got a column name wrong before I run anything.
let user = sqlx::query_as!(
User,
"SELECT id, email, created_at FROM users WHERE id = $1",
user_id
)
.fetch_one(&pool)
.await?;
If I rename email to email_address in the database, this code refuses to compile. Not at runtime. At compile time. That alone was worth the switch from the ORM I had been using.
You do have to set DATABASE_URL for the macros to work, and if you don’t want every CI run to hit a real Postgres, you cache the query metadata with cargo sqlx prepare. The sqlx docs cover this, but it’s the kind of thing nobody tells you on day one. The same docs cover the migration runner. I used to write my own runner and it was a waste of an afternoon.
Pool sizing: the only sqlx config I tune
The default PgPool settings are fine for a dev loop and disastrous in production. The defaults give you up to ten connections and no acquire timeout. The first time I deployed this stack, a slow query held the pool open and the next 200 requests piled up waiting for a free connection. The service didn’t crash. It just stopped responding, which is worse.
The config I use looks like this:
use sqlx::postgres::PgPoolOptions;
use std::time::Duration;
let pool = PgPoolOptions::new()
.max_connections(20)
.min_connections(2)
.acquire_timeout(Duration::from_secs(3))
.idle_timeout(Duration::from_secs(60 * 10))
.connect(&std::env::var("DATABASE_URL")?)
.await?;
max_connections should be lower than your Postgres max_connections setting divided by the number of service replicas. acquire_timeout is the one nobody sets and everyone wishes they had after their first outage. If a handler can’t grab a connection in three seconds, you want it to fail fast and surface the error, not silently queue forever.
State plumbing: the thing that always bites people
Sharing a database pool across handlers in axum looks simple until you hit your first lifetime error. Here’s the pattern I use.
use axum::{extract::State, routing::get, Router, Json};
use sqlx::PgPool;
#[derive(Clone)]
struct AppState {
db: PgPool,
}
async fn list_users(
State(state): State<AppState>,
) -> Result<Json<Vec<User>>, AppError> {
let users = sqlx::query_as!(
User,
"SELECT id, email, created_at FROM users"
)
.fetch_all(&state.db)
.await?;
Ok(Json(users))
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
let state = AppState { db: pool };
let app = Router::new()
.route("/users", get(list_users))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
Two things I got wrong the first three times. First, AppState has to be Clone. PgPool is itself an Arc internally, so cloning is cheap. Do not put a Mutex<PgPool> in there. Second, the State extractor in the handler has to match the type you passed to with_state. If you have multiple sub-states, look at FromRef. The compiler error when you get this wrong is dense and unhelpful, and that’s the bit that ate my Saturday.
Error handling without the .unwrap() graveyard
The pattern I settled on is a single AppError enum that implements IntoResponse. This is the thing that turned my early axum code from “Rust with sprinkled panics” into something I’d actually deploy.
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
#[error("not found")]
NotFound,
#[error(transparent)]
Other(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound => (
StatusCode::NOT_FOUND,
"not found".to_string(),
),
AppError::Database(sqlx::Error::RowNotFound) => (
StatusCode::NOT_FOUND,
"not found".to_string(),
),
_ => {
tracing::error!(error = ?self, "request failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_string(),
)
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
Now every handler returns Result<T, AppError> and the ? operator does the heavy lifting. The #[from] sqlx::Error is the trick. It auto-converts pool failures, deserialization errors, the lot. Pair this with tower_http::trace::TraceLayer and you get structured error logs without thinking about it.
This pattern came from the axum examples repo, which I should have read on day one instead of day fourteen.
Middleware and tracing, in two minutes
I add three layers to almost every service: a request timeout, a tracing layer, and CORS if the service talks to a browser.
use tower_http::{
trace::TraceLayer,
timeout::TimeoutLayer,
cors::CorsLayer,
};
use std::time::Duration;
let app = Router::new()
.route("/users", get(list_users))
.layer(TimeoutLayer::new(Duration::from_secs(10)))
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive()) // tighten in prod
.with_state(state);
CorsLayer::permissive is fine for a local dev demo and a horrible idea anywhere else. Set explicit origins before you ship. I have shipped permissive CORS once. I will not be doing that again.
For tracing, set RUST_LOG=info,sqlx=warn in your environment. sqlx’s query logs at debug level are noisy enough to drown out anything useful. The EnvFilter from tracing-subscriber reads that variable and Just Works.
Testing against a real database
I used to mock the database client. Every single time, my tests went green and production blew up on a constraint I’d forgotten to mirror in the mock. Now I run integration tests against a real Postgres in Docker.
#[sqlx::test]
async fn list_users_returns_seeded_users(pool: PgPool) {
sqlx::query!("INSERT INTO users (id, email) VALUES ($1, $2)",
uuid::Uuid::new_v4(), "[email protected]")
.execute(&pool).await.unwrap();
let users = repo::list_users(&pool).await.unwrap();
assert_eq!(users.len(), 1);
}
The #[sqlx::test] macro spins up a fresh database per test, runs your migrations, and hands you a pool. It’s the most underrated feature in the whole library. I’d been hand-rolling test harnesses for a year before I noticed it existed.
What I’d do differently on the next service
A few things I’d skip the rituals on. I’d reach for sqlx’s migration runner immediately instead of writing my own. I’d put a make dev target in the Makefile from day one that spins up Postgres in Docker, runs migrations, and starts the service with cargo watch -x run. And I’d wire up tower_http::trace before I write the second handler, because debugging a Rust service without structured request logs is its own special kind of pain.
If you want to see the kind of stuff I build with this stack, I keep a few projects on my portfolio. Most have a Rust service somewhere in the backend.
One thing to try this week
Pick the smallest internal tool you have. A status endpoint or a webhook receiver. Rewrite it as an axum + sqlx service. You’ll hit every pattern in this post in about three hours, the borrow checker will yell at you twice, and at the end you’ll have a binary that uses 12 megabytes of RAM. That’s the point Rust on the backend starts feeling reasonable instead of academic.