{"id":390,"date":"2026-06-27T13:02:36","date_gmt":"2026-06-27T13:02:36","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/axum-sqlx-rust-backend-setup-i-actually-use-in-production\/"},"modified":"2026-06-27T13:02:36","modified_gmt":"2026-06-27T13:02:36","slug":"axum-sqlx-rust-backend-setup-i-actually-use-in-production","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/axum-sqlx-rust-backend-setup-i-actually-use-in-production\/","title":{"rendered":"Axum + sqlx: The Rust Backend Setup I Actually Use in Production"},"content":{"rendered":"<p>A few months back I wrote about <a href=\"https:\/\/abrarqasim.com\/blog\/picking-between-axum-actix-rocket-my-2026-decision-tree\" rel=\"noopener\">picking between axum, actix, and rocket<\/a>. I picked axum on the next project, then spent a weekend Googling things like &ldquo;axum sqlx state extractor not working.&rdquo; The official docs are fine but scattered, and most tutorials online stop at hello-world.<\/p>\n<p>This post is the setup I actually have in production. Not the perfect one. The one that hasn&rsquo;t woken me up at 3am yet. If you&rsquo;ve already picked axum and want to wire it up to Postgres without writing your own ORM, here&rsquo;s what I do.<\/p>\n<p>I&rsquo;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 <code>.unwrap()<\/code> everywhere. Real code, the kind I copy-paste into new services.<\/p>\n<h2 id=\"why-axum-briefly\">Why axum, briefly<\/h2>\n<p>I&rsquo;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&rsquo;d like. axum sits on top of tokio and hyper, both of which have years of production miles. Read <a href=\"https:\/\/docs.rs\/axum\/latest\/axum\/\" rel=\"nofollow noopener\" target=\"_blank\">the axum docs<\/a> before you write a line of code, especially the page on extractors.<\/p>\n<p>Picking axum was the easy part. The harder part was figuring out the rest of the stack without ending up in dependency hell.<\/p>\n<h2 id=\"the-cargotoml-i-actually-start-with\">The Cargo.toml I actually start with<\/h2>\n<p>Here&rsquo;s the dependency block I copy-paste into every new axum service. I trimmed the project-specific bits.<\/p>\n<pre><code class=\"language-toml\">[dependencies]\naxum = &quot;0.7&quot;\ntokio = { version = &quot;1&quot;, features = [&quot;full&quot;] }\nsqlx = { version = &quot;0.8&quot;, features = [&quot;runtime-tokio&quot;, &quot;postgres&quot;, &quot;uuid&quot;, &quot;chrono&quot;, &quot;macros&quot;, &quot;migrate&quot;] }\ntower = &quot;0.5&quot;\ntower-http = { version = &quot;0.6&quot;, features = [&quot;trace&quot;, &quot;cors&quot;, &quot;timeout&quot;] }\ntracing = &quot;0.1&quot;\ntracing-subscriber = { version = &quot;0.3&quot;, features = [&quot;env-filter&quot;] }\nserde = { version = &quot;1&quot;, features = [&quot;derive&quot;] }\nserde_json = &quot;1&quot;\nanyhow = &quot;1&quot;\nthiserror = &quot;2&quot;\nuuid = { version = &quot;1&quot;, features = [&quot;v4&quot;, &quot;serde&quot;] }\nchrono = { version = &quot;0.4&quot;, features = [&quot;serde&quot;] }\n<\/code><\/pre>\n<p>Two things worth calling out. The <code>tokio = \"full\"<\/code> flag is lazy. It&rsquo;s what I do at the start, then trim once I know which features I actually need. The <code>sqlx<\/code> <code>macros<\/code> feature is the one I care about most. It&rsquo;s what gives you compile-time checked SQL queries against your real database schema. More on that in a second.<\/p>\n<p>A note on <code>anyhow<\/code> plus <code>thiserror<\/code>. They are not the same thing and they are not interchangeable. <code>anyhow<\/code> is for application code where you just want the error to bubble. <code>thiserror<\/code> is for library boundaries and your API error type. Use both. They cost almost nothing.<\/p>\n<h2 id=\"sqlx-is-the-actual-reason-i-picked-this-stack\">sqlx is the actual reason I picked this stack<\/h2>\n<p>I&rsquo;ve tried diesel. I&rsquo;ve tried seaorm. Both are fine. The thing that made me stick with sqlx is that it doesn&rsquo;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.<\/p>\n<pre><code class=\"language-rust\">let user = sqlx::query_as!(\n    User,\n    &quot;SELECT id, email, created_at FROM users WHERE id = $1&quot;,\n    user_id\n)\n.fetch_one(&amp;pool)\n.await?;\n<\/code><\/pre>\n<p>If I rename <code>email<\/code> to <code>email_address<\/code> 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.<\/p>\n<p>You do have to set <code>DATABASE_URL<\/code> for the macros to work, and if you don&rsquo;t want every CI run to hit a real Postgres, you cache the query metadata with <code>cargo sqlx prepare<\/code>. The <a href=\"https:\/\/docs.rs\/sqlx\/latest\/sqlx\/\" rel=\"nofollow noopener\" target=\"_blank\">sqlx docs<\/a> cover this, but it&rsquo;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.<\/p>\n<h2 id=\"pool-sizing-the-only-sqlx-config-i-tune\">Pool sizing: the only sqlx config I tune<\/h2>\n<p>The default <code>PgPool<\/code> 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&rsquo;t crash. It just stopped responding, which is worse.<\/p>\n<p>The config I use looks like this:<\/p>\n<pre><code class=\"language-rust\">use sqlx::postgres::PgPoolOptions;\nuse std::time::Duration;\n\nlet pool = PgPoolOptions::new()\n    .max_connections(20)\n    .min_connections(2)\n    .acquire_timeout(Duration::from_secs(3))\n    .idle_timeout(Duration::from_secs(60 * 10))\n    .connect(&amp;std::env::var(&quot;DATABASE_URL&quot;)?)\n    .await?;\n<\/code><\/pre>\n<p><code>max_connections<\/code> should be lower than your Postgres <code>max_connections<\/code> setting divided by the number of service replicas. <code>acquire_timeout<\/code> is the one nobody sets and everyone wishes they had after their first outage. If a handler can&rsquo;t grab a connection in three seconds, you want it to fail fast and surface the error, not silently queue forever.<\/p>\n<h2 id=\"state-plumbing-the-thing-that-always-bites-people\">State plumbing: the thing that always bites people<\/h2>\n<p>Sharing a database pool across handlers in axum looks simple until you hit your first lifetime error. Here&rsquo;s the pattern I use.<\/p>\n<pre><code class=\"language-rust\">use axum::{extract::State, routing::get, Router, Json};\nuse sqlx::PgPool;\n\n#[derive(Clone)]\nstruct AppState {\n    db: PgPool,\n}\n\nasync fn list_users(\n    State(state): State&lt;AppState&gt;,\n) -&gt; Result&lt;Json&lt;Vec&lt;User&gt;&gt;, AppError&gt; {\n    let users = sqlx::query_as!(\n        User,\n        &quot;SELECT id, email, created_at FROM users&quot;\n    )\n    .fetch_all(&amp;state.db)\n    .await?;\n    Ok(Json(users))\n}\n\n#[tokio::main]\nasync fn main() -&gt; anyhow::Result&lt;()&gt; {\n    let pool = PgPool::connect(&amp;std::env::var(&quot;DATABASE_URL&quot;)?).await?;\n    let state = AppState { db: pool };\n\n    let app = Router::new()\n        .route(&quot;\/users&quot;, get(list_users))\n        .with_state(state);\n\n    let listener = tokio::net::TcpListener::bind(&quot;0.0.0.0:3000&quot;).await?;\n    axum::serve(listener, app).await?;\n    Ok(())\n}\n<\/code><\/pre>\n<p>Two things I got wrong the first three times. First, <code>AppState<\/code> has to be <code>Clone<\/code>. <code>PgPool<\/code> is itself an <code>Arc<\/code> internally, so cloning is cheap. Do not put a <code>Mutex&lt;PgPool&gt;<\/code> in there. Second, the <code>State<\/code> extractor in the handler has to match the type you passed to <code>with_state<\/code>. If you have multiple sub-states, look at <code>FromRef<\/code>. The compiler error when you get this wrong is dense and unhelpful, and that&rsquo;s the bit that ate my Saturday.<\/p>\n<h2 id=\"error-handling-without-the-unwrap-graveyard\">Error handling without the <code>.unwrap()<\/code> graveyard<\/h2>\n<p>The pattern I settled on is a single <code>AppError<\/code> enum that implements <code>IntoResponse<\/code>. This is the thing that turned my early axum code from &ldquo;Rust with sprinkled panics&rdquo; into something I&rsquo;d actually deploy.<\/p>\n<pre><code class=\"language-rust\">use axum::{\n    http::StatusCode,\n    response::{IntoResponse, Response},\n    Json,\n};\nuse serde_json::json;\nuse thiserror::Error;\n\n#[derive(Debug, Error)]\npub enum AppError {\n    #[error(&quot;database error: {0}&quot;)]\n    Database(#[from] sqlx::Error),\n    #[error(&quot;not found&quot;)]\n    NotFound,\n    #[error(transparent)]\n    Other(#[from] anyhow::Error),\n}\n\nimpl IntoResponse for AppError {\n    fn into_response(self) -&gt; Response {\n        let (status, message) = match &amp;self {\n            AppError::NotFound =&gt; (\n                StatusCode::NOT_FOUND,\n                &quot;not found&quot;.to_string(),\n            ),\n            AppError::Database(sqlx::Error::RowNotFound) =&gt; (\n                StatusCode::NOT_FOUND,\n                &quot;not found&quot;.to_string(),\n            ),\n            _ =&gt; {\n                tracing::error!(error = ?self, &quot;request failed&quot;);\n                (\n                    StatusCode::INTERNAL_SERVER_ERROR,\n                    &quot;internal error&quot;.to_string(),\n                )\n            }\n        };\n        (status, Json(json!({ &quot;error&quot;: message }))).into_response()\n    }\n}\n<\/code><\/pre>\n<p>Now every handler returns <code>Result&lt;T, AppError&gt;<\/code> and the <code>?<\/code> operator does the heavy lifting. The <code>#[from] sqlx::Error<\/code> is the trick. It auto-converts pool failures, deserialization errors, the lot. Pair this with <code>tower_http::trace::TraceLayer<\/code> and you get structured error logs without thinking about it.<\/p>\n<p>This pattern came from <a href=\"https:\/\/github.com\/tokio-rs\/axum\/tree\/main\/examples\" rel=\"nofollow noopener\" target=\"_blank\">the axum examples repo<\/a>, which I should have read on day one instead of day fourteen.<\/p>\n<h2 id=\"middleware-and-tracing-in-two-minutes\">Middleware and tracing, in two minutes<\/h2>\n<p>I add three layers to almost every service: a request timeout, a tracing layer, and CORS if the service talks to a browser.<\/p>\n<pre><code class=\"language-rust\">use tower_http::{\n    trace::TraceLayer,\n    timeout::TimeoutLayer,\n    cors::CorsLayer,\n};\nuse std::time::Duration;\n\nlet app = Router::new()\n    .route(&quot;\/users&quot;, get(list_users))\n    .layer(TimeoutLayer::new(Duration::from_secs(10)))\n    .layer(TraceLayer::new_for_http())\n    .layer(CorsLayer::permissive()) \/\/ tighten in prod\n    .with_state(state);\n<\/code><\/pre>\n<p><code>CorsLayer::permissive<\/code> 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.<\/p>\n<p>For tracing, set <code>RUST_LOG=info,sqlx=warn<\/code> in your environment. sqlx&rsquo;s query logs at debug level are noisy enough to drown out anything useful. The <code>EnvFilter<\/code> from <code>tracing-subscriber<\/code> reads that variable and Just Works.<\/p>\n<h2 id=\"testing-against-a-real-database\">Testing against a real database<\/h2>\n<p>I used to mock the database client. Every single time, my tests went green and production blew up on a constraint I&rsquo;d forgotten to mirror in the mock. Now I run integration tests against a real Postgres in Docker.<\/p>\n<pre><code class=\"language-rust\">#[sqlx::test]\nasync fn list_users_returns_seeded_users(pool: PgPool) {\n    sqlx::query!(&quot;INSERT INTO users (id, email) VALUES ($1, $2)&quot;,\n        uuid::Uuid::new_v4(), &quot;test@example.com&quot;)\n        .execute(&amp;pool).await.unwrap();\n\n    let users = repo::list_users(&amp;pool).await.unwrap();\n    assert_eq!(users.len(), 1);\n}\n<\/code><\/pre>\n<p>The <code>#[sqlx::test]<\/code> macro spins up a fresh database per test, runs your migrations, and hands you a pool. It&rsquo;s the most underrated feature in the whole library. I&rsquo;d been hand-rolling test harnesses for a year before I noticed it existed.<\/p>\n<h2 id=\"what-id-do-differently-on-the-next-service\">What I&rsquo;d do differently on the next service<\/h2>\n<p>A few things I&rsquo;d skip the rituals on. I&rsquo;d reach for sqlx&rsquo;s migration runner immediately instead of writing my own. I&rsquo;d put a <code>make dev<\/code> target in the Makefile from day one that spins up Postgres in Docker, runs migrations, and starts the service with <code>cargo watch -x run<\/code>. And I&rsquo;d wire up <code>tower_http::trace<\/code> before I write the second handler, because debugging a Rust service without structured request logs is its own special kind of pain.<\/p>\n<p>If you want to see the kind of stuff I build with this stack, I keep a few projects on my <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">portfolio<\/a>. Most have a Rust service somewhere in the backend.<\/p>\n<h2 id=\"one-thing-to-try-this-week\">One thing to try this week<\/h2>\n<p>Pick the smallest internal tool you have. A status endpoint or a webhook receiver. Rewrite it as an axum + sqlx service. You&rsquo;ll hit every pattern in this post in about three hours, the borrow checker will yell at you twice, and at the end you&rsquo;ll have a binary that uses 12 megabytes of RAM. That&rsquo;s the point Rust on the backend starts feeling reasonable instead of academic.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I picked axum for a Rust service and spent a weekend on basics nobody documents. Here&#8217;s the axum + sqlx production setup I actually ship, with real code.<\/p>\n","protected":false},"author":2,"featured_media":389,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I picked axum for a Rust service and spent a weekend on basics nobody documents. Here's the axum + sqlx production setup I actually ship, with real code.","rank_math_focus_keyword":"axum rust","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[147,142],"tags":[117,49,64,419,176,418],"class_list":["post-390","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-backend","category-rust","tag-axum","tag-backend","tag-rust","tag-rust-web","tag-sqlx","tag-tokio"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/390","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=390"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/390\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/389"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=390"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=390"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=390"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}