SQLx is one of Rust’s killer apps for backend work. Type-safe SQL at compile time without an ORM. This post is the working set.
Setup
[dependencies]
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "macros", "uuid", "chrono"] }
use sqlx::postgres::PgPoolOptions;
let pool = PgPoolOptions::new()
.max_connections(20)
.acquire_timeout(Duration::from_secs(3))
.connect(&env::var("DATABASE_URL")?).await?;
Pool is Clone-cheap (Arc inside); pass it everywhere.
query! macro
let row = sqlx::query!("SELECT id, email FROM users WHERE id = $1", user_id)
.fetch_one(&pool).await?;
println!("{} - {}", row.id, row.email);
At compile time, SQLx connects to your dev DB, validates the SQL, and gives you a typed struct. Mismatches fail to compile.
query_as! for explicit types
#[derive(sqlx::FromRow)]
struct User { id: i64, email: String, name: Option<String> }
let user = sqlx::query_as!(User, "SELECT id, email, name FROM users WHERE id = $1", user_id)
.fetch_one(&pool).await?;
Returns User; fields must match SELECT.
Offline mode
For CI without DB access:
cargo install sqlx-cli
sqlx prepare # writes .sqlx/ with cached query metadata
git add .sqlx/
CI builds use the cached metadata; no live DB needed.
fetch_one / fetch_optional / fetch_all
sqlx::query!("...").fetch_one(&pool).await?; // exactly one row; error if 0
sqlx::query!("...").fetch_optional(&pool).await?; // Option<Row>; None if 0
sqlx::query!("...").fetch_all(&pool).await?; // Vec<Row>
sqlx::query!("...").execute(&pool).await?; // INSERT/UPDATE/DELETE
Pick by intent.
Transactions
let mut tx = pool.begin().await?;
sqlx::query!("UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, from)
.execute(&mut *tx).await?;
sqlx::query!("UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, to)
.execute(&mut *tx).await?;
tx.commit().await?; // explicit; drops without commit = rollback
&mut *tx — tx derefs to a connection. Quirky but consistent across SQLx APIs.
Streaming large result sets
use futures::StreamExt;
let mut rows = sqlx::query!("SELECT id, email FROM users").fetch(&pool);
while let Some(row) = rows.try_next().await? {
process(row.id, &row.email);
}
Doesn’t load everything in memory. Critical for big tables.
Migrations
sqlx migrate add create_users
# edits migrations/<timestamp>_create_users.sql
sqlx migrate run
sqlx migrate revert
Embed in app:
sqlx::migrate!("./migrations").run(&pool).await?;
App runs migrations on startup. Fail-fast if migration fails.
Custom types
#[derive(sqlx::Type)]
#[sqlx(type_name = "user_status", rename_all = "lowercase")]
enum UserStatus { Active, Suspended, Deleted }
#[derive(sqlx::FromRow)]
struct User { id: i64, status: UserStatus }
Custom enum mapped to Postgres enum.
JSON
use sqlx::types::Json;
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, sqlx::Type)]
struct Prefs { theme: String, notifications: bool }
let row = sqlx::query!(
r#"SELECT id, prefs as "prefs: Json<Prefs>" FROM users WHERE id = $1"#,
user_id
).fetch_one(&pool).await?;
Postgres JSON/JSONB ↔ Rust struct via serde.
Dynamic SQL
For queries built at runtime:
use sqlx::QueryBuilder;
let mut qb = QueryBuilder::new("SELECT id, email FROM users WHERE 1=1 ");
if let Some(name) = filter.name {
qb.push(" AND name = ").push_bind(name);
}
let rows = qb.build().fetch_all(&pool).await?;
Parameter binding still escapes. No SQL injection.
Error handling
match sqlx::query!("INSERT INTO users (email) VALUES ($1)", email)
.execute(&pool).await
{
Ok(_) => Ok(()),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
Err(AppError::EmailTaken)
}
Err(e) => Err(e.into()),
}
Distinguish constraint violations from genuine DB errors. See Rust Error Handling .
Patterns from production
Repository pattern
pub struct UserRepo { pool: PgPool }
impl UserRepo {
pub async fn create(&self, email: &str) -> Result<User, AppError> {
let user = sqlx::query_as!(User,
"INSERT INTO users (email) VALUES ($1) RETURNING id, email, created_at",
email
).fetch_one(&self.pool).await?;
Ok(user)
}
}
Centralized SQL; testable; easy to mock at the repo trait level.
Pagination
let users = sqlx::query_as!(User,
"SELECT id, email FROM users WHERE id > $1 ORDER BY id LIMIT $2",
cursor, page_size as i64
).fetch_all(&pool).await?;
Cursor-based; stable under inserts.
Bulk insert
sqlx::query!(
"INSERT INTO users (email) SELECT * FROM UNNEST($1::text[])",
&emails[..]
).execute(&pool).await?;
Single round-trip for thousands of inserts. Much faster than a loop.
Common mistakes
1. Connections per request
let conn = PgConnection::connect(...).await?; // BAD
Use the pool. Cloning the pool is cheap; opening connections is expensive.
2. No timeout
A bad query hangs forever. Set acquire_timeout and per-query timeouts (via tokio::time::timeout).
3. SELECT *
query! will type all columns including ones you don’t need. Wide tables → wasted bytes. Specify columns.
4. Skipping transactions
Multi-step writes without a transaction → partial state on errors. Wrap in pool.begin().
5. Ignoring offline mode in CI
CI builds need DB or .sqlx/ cache. Decide which; set up accordingly.
What I’d ship today
For Rust + Postgres backend:
- SQLx with
query!/query_as!macros. - Pool sized appropriately (test with
pgbench). - Repository pattern for testability.
- Migrations embedded; run at startup.
- OTEL instrumentation for query traces.
- pgcat / PgBouncer in front for transaction-pooling.
Read this next
- Production Rust on Axum
- Rust Error Handling 2026
- Tokio Async Fundamentals
- Postgres Connection Pooling 2026
If you want my Rust + SQLx + Axum production starter, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .