{"id":168,"date":"2026-04-29T13:03:56","date_gmt":"2026-04-29T13:03:56","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/rust-sqlx-production-eight-months-of-lessons\/"},"modified":"2026-04-29T13:03:56","modified_gmt":"2026-04-29T13:03:56","slug":"rust-sqlx-production-eight-months-of-lessons","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/rust-sqlx-production-eight-months-of-lessons\/","title":{"rendered":"Rust sqlx in Production: 8 Months of Lessons"},"content":{"rendered":"<p>Confession: when I first added sqlx to a Rust service, I spent half a day fighting it. The compile-time SQL checks felt magical until I tried to run <code>cargo check<\/code> with the database offline. Then the magic became a rake to the face.<\/p>\n<p>Eight months later that same service is in production, has handled a few million requests, and I haven&rsquo;t ripped sqlx out. I&rsquo;ve also figured out which features I actually use, which ones I avoided after the first burn, and which docs I wish someone had handed me on day one.<\/p>\n<p>This isn&rsquo;t a tutorial. The official <a href=\"https:\/\/github.com\/launchbadge\/sqlx\" rel=\"nofollow noopener\" target=\"_blank\">sqlx README<\/a> does that job fine. This is the post-deployment write-up. What bit me, what I now do without thinking, and the small handful of patterns that turned sqlx from &ldquo;interesting library&rdquo; into &ldquo;boring infrastructure&rdquo; in my codebase. If you&rsquo;re picking a database layer for an Axum or Actix backend (I covered the framework choice in <a href=\"https:\/\/abrarqasim.com\/blog\/rust-web-frameworks-2026-axum-actix-rocket\" rel=\"noopener\">my Rust web frameworks roundup<\/a>), read on.<\/p>\n<h2 id=\"the-compile-time-check-is-great-until-it-isnt\">The compile-time check is great. Until it isn&rsquo;t.<\/h2>\n<p>The selling point of sqlx is <code>sqlx::query!()<\/code>, a macro that connects to a real database at compile time, runs an EXPLAIN, and gives you a struct typed to your actual schema. This is wonderful when it works.<\/p>\n<p>Here&rsquo;s what bit me: it requires <code>DATABASE_URL<\/code> to be set and the database to be reachable when you run <code>cargo check<\/code>. Fine on my laptop. Not fine in CI, not fine on a colleague&rsquo;s machine, and not fine when you want to ship a Docker image build that doesn&rsquo;t depend on a live Postgres.<\/p>\n<p>The fix is offline mode. Run <code>cargo sqlx prepare<\/code> once with the database connected, commit the resulting <code>.sqlx\/<\/code> directory, and your builds work without a database. I now treat this as part of the workflow, not an afterthought:<\/p>\n<pre><code class=\"language-bash\"># After any change to a query!() macro\ncargo sqlx prepare\ngit add .sqlx\ngit commit -m &quot;Update sqlx query metadata&quot;\n<\/code><\/pre>\n<p>In CI, I run <code>cargo sqlx prepare --check<\/code> to fail the build if the cached metadata is stale. That single line has saved me three &ldquo;works on my machine&rdquo; incidents.<\/p>\n<h2 id=\"sqlxquery-vs-sqlxquery-i-use-both\">sqlx::query! vs sqlx::query: I use both<\/h2>\n<p>I see new projects pick one and stick with it. After a while, you want both.<\/p>\n<p>Use <code>query!()<\/code> and <code>query_as!()<\/code> for everything where the SQL is fixed and the result shape is known. You get compile-time SQL validation and a typed struct back. No <code>.try_get(\"column\")<\/code> calls, no string typos at runtime.<\/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>Use plain <code>query()<\/code> and <code>query_as()<\/code> (no exclamation mark) when you build SQL dynamically. Search filters, optional conditions, anything where the WHERE clause depends on runtime input. The macros can&rsquo;t know your SQL ahead of time if you stitch it together at runtime.<\/p>\n<pre><code class=\"language-rust\">let mut sql = String::from(&quot;SELECT * FROM events WHERE 1=1&quot;);\nif after_id.is_some() {\n    sql.push_str(&quot; AND id &gt; $1&quot;);\n}\n\nlet rows = sqlx::query_as::&lt;_, Event&gt;(&amp;sql)\n    .bind(after_id)\n    .fetch_all(&amp;pool)\n    .await?;\n<\/code><\/pre>\n<p>The mistake I see most often is people reaching for the <code>QueryBuilder<\/code> for everything. It&rsquo;s there for a reason, but for static queries the macros win on safety. The <a href=\"https:\/\/docs.rs\/sqlx\" rel=\"nofollow noopener\" target=\"_blank\">docs.rs page for sqlx<\/a> lays out the trait bounds explicitly if you want to dig into why the type inference works the way it does.<\/p>\n<h2 id=\"connection-pool-stop-copying-the-default\">Connection pool: stop copying the default<\/h2>\n<p>Every sqlx tutorial I&rsquo;ve read uses <code>PgPoolOptions::new().connect(url).await?<\/code>. That gives you a pool with <code>max_connections = 10<\/code> and an <code>acquire_timeout<\/code> of 30 seconds. Both are wrong for most production services.<\/p>\n<p>Postgres has a hard cap on <code>max_connections<\/code> (default 100). If your service has 4 instances each opening 10 connections, you&rsquo;ve used 40% of the cap before any other service touches the database. I&rsquo;ve seen &ldquo;we ran out of Postgres connections&rdquo; outages caused by exactly this.<\/p>\n<p>What I actually configure now:<\/p>\n<pre><code class=\"language-rust\">let pool = PgPoolOptions::new()\n    .max_connections(20)\n    .min_connections(5)\n    .acquire_timeout(Duration::from_secs(3))\n    .idle_timeout(Duration::from_secs(60 * 10))\n    .max_lifetime(Duration::from_secs(60 * 30))\n    .connect(&amp;database_url)\n    .await?;\n<\/code><\/pre>\n<p>Three things changed. <code>acquire_timeout<\/code> is short: if I can&rsquo;t get a connection in 3 seconds, something is wrong and I&rsquo;d rather fail the request than queue. <code>idle_timeout<\/code> and <code>max_lifetime<\/code> are both set so connections recycle, because Postgres connections that live forever pin memory and can outlive load balancer changes. <code>min_connections<\/code> keeps a few warm so the first request after idle isn&rsquo;t slow.<\/p>\n<p>These numbers aren&rsquo;t universal. They&rsquo;re the starting point I tune from.<\/p>\n<h2 id=\"transactions-and-the-but-the-trait-problem\">Transactions and the &ldquo;but the trait&rdquo; problem<\/h2>\n<p>This is the sqlx footgun that gets every Rust beginner. You write a helper function that takes a <code>&amp;PgPool<\/code>, then you wrap a few helpers in a transaction and the borrow checker explodes.<\/p>\n<pre><code class=\"language-rust\">\/\/ Doesn't work the way you'd hope:\nasync fn create_user(pool: &amp;PgPool, email: &amp;str) -&gt; Result&lt;User&gt; {\n    sqlx::query_as!(User, &quot;INSERT INTO users (email) VALUES ($1) RETURNING *&quot;, email)\n        .fetch_one(pool)\n        .await\n}\n\n\/\/ You can't pass the transaction here without changing the signature.\nlet mut tx = pool.begin().await?;\nlet user = create_user(&amp;tx, &quot;test@example.com&quot;).await?; \/\/ ERROR\ntx.commit().await?;\n<\/code><\/pre>\n<p>The fix is to make your helpers generic over <code>Executor<\/code>:<\/p>\n<pre><code class=\"language-rust\">async fn create_user&lt;'e, E&gt;(executor: E, email: &amp;str) -&gt; Result&lt;User&gt;\nwhere\n    E: sqlx::Executor&lt;'e, Database = sqlx::Postgres&gt;,\n{\n    sqlx::query_as!(User, &quot;INSERT INTO users (email) VALUES ($1) RETURNING *&quot;, email)\n        .fetch_one(executor)\n        .await\n}\n<\/code><\/pre>\n<p>Now both <code>&amp;pool<\/code> and <code>&amp;mut tx<\/code> work. I write almost every database helper this way. The signature looks ugly the first time but you stop noticing after a week, and you never have to choose between transaction-aware and pool-aware versions of the same function. If the lifetime annotation feels mysterious, my older <a href=\"https:\/\/abrarqasim.com\/blog\/rust-memory-safety-what-the-borrow-checker-actually-does\" rel=\"noopener\">borrow checker writeup<\/a> covers why <code>'e<\/code> shows up here.<\/p>\n<h2 id=\"migrations-yes-just-use-sqlx-cli\">Migrations: yes, just use sqlx-cli<\/h2>\n<p>I tried Refinery first because it had a nicer Rust-side API. I switched back to <code>sqlx-cli<\/code> after a month because the integration with the macros is too useful to give up. <code>sqlx prepare<\/code> and <code>sqlx migrate run<\/code> share state, and the migrations directory layout is what <code>query!()<\/code> introspects against.<\/p>\n<pre><code class=\"language-bash\"># Once\ncargo install sqlx-cli --no-default-features --features postgres\n\n# New migration\nsqlx migrate add -r create_users_table\n\n# Run it\nsqlx migrate run\n\n# In your binary\nsqlx::migrate!().run(&amp;pool).await?;\n<\/code><\/pre>\n<p><code>sqlx::migrate!()<\/code> reads the <code>migrations\/<\/code> folder at compile time and embeds the SQL into your binary. No filesystem dependency at runtime. This matters when you ship a Docker image, because the container does not need the migrations directory.<\/p>\n<p>One thing to know: <code>sqlx migrate add -r<\/code> creates a paired <code>.up.sql<\/code> and <code>.down.sql<\/code>. The <code>-r<\/code> is for &ldquo;reversible&rdquo;. I&rsquo;ve never actually run a down migration in production (I roll forward), but the discipline of writing one forces you to think about whether the change is safe to undo. Worth the 30 seconds.<\/p>\n<h2 id=\"what-id-do-differently\">What I&rsquo;d do differently<\/h2>\n<p>If I were starting today, three changes from my original setup.<\/p>\n<p>First, enable offline mode on day one. Don&rsquo;t wait until the first CI failure to learn <code>cargo sqlx prepare<\/code>. The friction of &ldquo;did I forget to commit <code>.sqlx\/<\/code>?&rdquo; is low once it&rsquo;s a habit.<\/p>\n<p>Second, write the helper functions generic over <code>Executor<\/code> from the first commit. Refactoring a few hundred lines of database code to add the generics later is annoying. Doing it from the start costs you an extra type parameter you stop seeing within a week.<\/p>\n<p>Third, set the pool config explicitly, even for a hobby project. The defaults look reasonable and they&rsquo;re not. Spending five minutes on <code>max_connections<\/code> and <code>acquire_timeout<\/code> upfront prevents a class of incident that&rsquo;s painful to debug under load.<\/p>\n<p>If you&rsquo;re starting a new Rust service this week, do these three things in order: enable <code>SQLX_OFFLINE<\/code> in your build with <code>cargo sqlx prepare<\/code>, set <code>max_connections<\/code> in the pool builder, and add <code>cargo sqlx prepare --check<\/code> to CI. That&rsquo;s it. Everything else (query macros, transactions, migrations) you can pick up as you need them.<\/p>\n<p>I write up more of this kind of &ldquo;what actually shipped&rdquo; Rust work over on <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">my project page<\/a>. The <a href=\"https:\/\/rust-lang.github.io\/async-book\/\" rel=\"nofollow noopener\" target=\"_blank\">Rust async book<\/a> is still the right place to start if any of the await stuff in this post felt fast. It&rsquo;s the one official Rust resource I keep open in a tab.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I shipped sqlx in a Rust production service for 8 months. Here&#8217;s the patterns I keep, the footguns that bit me, and what I&#8217;d do differently from day one.<\/p>\n","protected":false},"author":2,"featured_media":167,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I shipped sqlx in a Rust production service for 8 months. Here's the patterns I keep, the footguns that bit me, and what I'd do differently from day one.","rank_math_focus_keyword":"sqlx rust","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[147,142],"tags":[179,49,178,177,64,176],"class_list":["post-168","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-backend","category-rust","tag-async","tag-backend","tag-database","tag-postgres","tag-rust","tag-sqlx"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/168","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=168"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/168\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/167"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=168"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=168"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=168"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}