The reason Rust `sqlx` catches SQL errors at the build stage rather than at runtime
If you've ever changed a schema with Prisma in a TypeScript backend, forgotten to run prisma generate, and then deployed — only to see PrismaClientKnownRequestError: The column 'X' does not exist in the current database in your production logs — you're not alone. A quick look at the column does not exist issue threads in the Prisma repository shows the same story repeating: schema, client, and migration state fall out of sync and blow up at runtime. TypeORM has the same structural problem, where drift between entity decorators and the actual DB schema only surfaces after deployment. No matter how powerful TypeScript's type system is, it has a fundamental limitation — it can't peer inside SQL strings.
Rust's sqlx tackles this problem head-on. The query! macro connects to a real database at cargo build time and validates SQL syntax, column existence, and type mappings all at once. If a query is wrong, the build simply fails. Runtime SQL errors are pulled up into the build phase.
This post covers how the query! macro actually works under the hood, how to use it in environments without a database — like CI/CD — via offline mode, and the common pitfalls you'll encounter in practice. If you have a TypeScript backend background and are touching a Rust DB layer for the first time, this should resonate.
What the query! Macro Actually Does
What "Compile-Time Validation" Means Concretely
sqlx is not an ORM. It's a toolkit for writing raw SQL that validates that SQL against a real DB at build time. Here's what happens, step by step, when a query! macro runs.
It checks three things: SQL syntax validity, whether the referenced columns actually exist, and whether the DB column types match the Rust types. PostgreSQL's INT4 maps to Rust i32, INT8 to i64, TEXT to String — any mismatch is a compile error.
The Three Macros and When to Use Them
| Macro | Return Type | When to Use |
|---|---|---|
sqlx::query! |
Anonymous struct | Quick one-shot queries |
sqlx::query_as! |
An explicitly defined struct | When you have a reusable struct |
sqlx::query_scalar! |
A single scalar type | Aggregate functions like COUNT(*) |
query_as! is what you'll reach for most often. It maps directly to a struct without needing #[derive(FromRow)].
#[derive(Debug)]
struct User {
id: i32,
email: String,
name: Option<String>, // nullable columns must be Option<T>
}
let user = sqlx::query_as!(
User,
r#"SELECT id, email, name FROM users WHERE id = $1"#,
user_id
)
.fetch_one(&pool)
.await?;I wrote name: String the first time and hit a compile error. The name column was nullable in the DB schema, and I hadn't used Option<String> on the Rust side. In TypeScript, a null would have slipped through at runtime and blown up later — catching it at build time was actually a relief.
From Setup to Queries
Cargo.toml Configuration
[dependencies]
sqlx = { version = "0.8", features = [
"runtime-tokio",
"tls-rustls",
"postgres",
"macros",
] }
tokio = { version = "1", features = ["full"] }
dotenvy = "0.15"As of 2026, the stable line is 0.8.x (check docs.rs/sqlx directly for the latest patch at time of reading). You need to list the DB driver (postgres, mysql, sqlite) and macros in features to use the query! macro. To connect over TLS to cloud PostgreSQL services like RDS, Supabase, or Neon, you also need tls-rustls (or tls-native-tls). Leave it out and you'll find that things work locally but SSL handshakes fail in staging.
Creating a Connection Pool
use sqlx::postgres::PgPoolOptions;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await?;
sqlx::migrate!("./migrations")
.run(&pool)
.await?;
Ok(())
}CRUD Examples
Fetch a single row
let user = sqlx::query_as!(
User,
"SELECT id, email, name FROM users WHERE id = $1",
user_id
)
.fetch_one(&pool)
.await?;Fetch a list
let users = sqlx::query_as!(
User,
"SELECT id, email, name FROM users ORDER BY id"
)
.fetch_all(&pool)
.await?;Insert and return
let new_user = sqlx::query_as!(
User,
"INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id, email, name",
email,
name
)
.fetch_one(&pool)
.await?;Aggregates
In PostgreSQL, COUNT(*) is reported as BIGINT NOT NULL, but sqlx's macro inference errs on the side of caution and types it as Option<i64>. This means you need .unwrap_or(0) on the fetch_one result. To lock it in as i64, you need to attach a type hint after the alias.
// Option 1: receive as Option<i64> and unwrap
let maybe_count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM users WHERE email = $1",
email
)
.fetch_one(&pool)
.await?;
let count: i64 = maybe_count.unwrap_or(0);
// Option 2: use a type hint to declare NOT NULL explicitly
let count: i64 = sqlx::query_scalar!(
r#"SELECT COUNT(*) as "count!: i64" FROM users WHERE email = $1"#,
email
)
.fetch_one(&pool)
.await?;The ! in as "count!: i64" is an override meaning "this column is guaranteed non-null." It's the syntax for telling sqlx explicitly what it can't safely infer on its own — like with aggregate functions.
Transactions
let mut tx = pool.begin().await?;
sqlx::query!(
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
amount,
from_id
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
amount,
to_id
)
.execute(&mut *tx)
.await?;
tx.commit().await?;Offline Mode — Building Without a DB in CI/CD
Compile-time validation is powerful, but it comes with one problem: you need a real database every time you build. Spinning up a DB on every run in a CI environment like GitHub Actions, or connecting to an external DB, is slow and cumbersome.
cargo sqlx prepare solves this.
Run it once locally.
# Install sqlx-cli (one-time setup)
cargo install sqlx-cli
# Save type metadata for all query! macros into .sqlx/
cargo sqlx prepareA .sqlx/ directory is created with a JSON file for each query. Commit it to git and you're done.
In CI, just add one environment variable.
# GitHub Actions example
- name: Build
env:
SQLX_OFFLINE: "true"
run: cargo build --releaseCatching .sqlx Drift in CI
The real trap with offline mode is forgetting to re-run cargo sqlx prepare after changing your schema. CI will validate against the stale .sqlx metadata, which means a broken query can silently pass.
There's an official command to prevent this.
cargo sqlx prepare --check--check doesn't write any files — it just verifies that the queries in your source match the committed .sqlx cache. Any mismatch exits with a non-zero code. Adding this as a separate step in your CI pipeline catches .sqlx sync failures at the PR stage.
- name: sqlx offline data check
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/app
run: cargo sqlx prepare --check --workspaceThe common pattern is to have only this job require a DB, then run actual build and test jobs with SQLX_OFFLINE=true. Pre-commit hooks aren't enforceable and vary by local environment, so putting --check in CI is the reliable approach.
Managing Migrations
# Create a new migration file
sqlx migrate add create_users_table
# Run migrations
sqlx migrate runThis creates timestamped .sql files in your migrations/ directory. The standard pattern is to run them automatically at app startup with the sqlx::migrate! macro.
Working with PostgreSQL Custom Types
Custom types like ENUM or domain types require extra handling. You use type override syntax.
// Define a custom type
#[derive(Debug, sqlx::Type)]
#[sqlx(type_name = "user_status", rename_all = "lowercase")]
enum UserStatus {
Active,
Inactive,
Banned,
}
// Provide type hints via column aliases
let rows = sqlx::query!(
r#"SELECT id as "id: i32", status as "status: UserStatus" FROM users"#
)
.fetch_all(&pool)
.await?;Something worth noting here: despite the central claim of this post — that sqlx catches SQL errors at compile time — custom types are a partial exception. To be precise:
- Caught at compile time: Whether the type (
user_status) exists in the DB, whether the column is that type, whethersqlx::Typeis implemented. - Only surfaced at runtime as a
ColumnDecodeerror: When the DB's enum labels (e.g.,'banned') don't match the actual mapping values of the Rust variants. For example, if the DB has a valuependingbut the Rustenumhas no such variant, it blows up the moment you read that row.
In other words, a typo in type_name or a mismatch between rename_all rules and actual DB labels means a runtime error. If you have many custom types, the boilerplate accumulates along with the ongoing burden of managing mapping drift.
Trade-offs — Honestly
Benefits
| Item | Practical Meaning |
|---|---|
| Eliminates runtime SQL errors | "column does not exist" errors after deployment are structurally prevented |
| Structural SQL injection prevention | Forces $1, $2 parameter binding; string formatting is impossible |
| Raw SQL | Use complex JOINs, CTEs, and window functions without a DSL |
Enforced Option<T> |
Missing NULL handling is caught at build time |
| Fully async | Built on tokio/async-std; composes naturally with Axum and Actix |
Realistic Drawbacks
| Item | Details |
|---|---|
| Increased compile time | ~80ms of overhead per query has been reported; with dozens of queries, cargo check becomes noticeably slow |
| DB required in dev environment | Until you set up offline mode, a live DB pointed to by DATABASE_URL is always needed |
.sqlx file synchronization |
Forgetting prepare means CI validates against stale metadata (→ defend with --check) |
| No ORM features | No relation mapping, no lazy loading; complex entity relationships require manual SQL |
| Verbose custom types | PostgreSQL enums require type override syntax plus ongoing mapping value management |
Compile time is a topic the community discusses continuously, with many issues and blog posts covering the per-query overhead. You'll feel it once you have a lot of queries.
Comparison with Other Libraries
| Library | Characteristics | When to Choose |
|---|---|---|
| sqlx | Async, raw SQL, compile-time validation | When performance and SQL control are the priority |
| SeaORM | Async, Active Record style, uses sqlx internally | When you prefer an ORM style with fast CRUD |
| Diesel | Sync by default, async available via diesel-async crate |
When you need a powerful query builder DSL |
| Cornucopia | Generates code from separate SQL files | When you want type safety with reduced compile times |
Coming from TypeScript
If you've used Prisma, you know the experience of editing schema.prisma, forgetting prisma generate, and hitting a runtime error. sqlx inverts this relationship. If the schema and queries don't agree, the build doesn't happen.
Parameter binding syntax is also different from TypeScript. Instead of Prisma's where: { id } style, you use positional bindings: $1, $2 (for PostgreSQL). It feels unfamiliar at first, but you get used to it quickly — and writing plain SQL makes collaboration with DB experts much smoother.
The Rust backend ecosystem hasn't converged on a single stack. Actix-web + sqlx remains widely used by crate download counts, while Axum + sqlx has appeared noticeably more often in docs and tutorials over the past few years. The sqlx org launchbadge maintains an official realworld-axum-sqlx reference implementation, which is a great starting point for understanding real-world REST API structure.
Closing — What It Means to Shift When Errors Occur
The argument of this post ultimately narrows to one thing: shift when errors occur.
Consider a thought experiment. A teammate merges a migration that renames the name column on the users table to full_name.
- TypeScript + Prisma:
prisma migrateran, butprisma generatewas skipped in CI or a service was built with a cached client. Deployment succeeds. The moment a code path withSELECT name FROM usersexecutes, production logs a 500 withcolumn "name" does not exist. You get paged and decide whether to roll back or ship a hotfix. - Rust + sqlx: After applying the migration,
cargo sqlx prepare --checkfails in CI. Regenerating.sqlxturns every query referencingnameinto a compile error. A deployment artifact is never produced. You encounter the error on a PR review screen, not in production.
The fundamental difference between these two scenarios isn't "how tight is the type system" — it's "when does the SQL string get validated." What sqlx does is move that validation point from after deployment to before the build. You pay for it with longer compile times and a DB requirement in your dev environment, but many teams find that a far cheaper price than a production SQL error alert.
References
- sqlx official GitHub
- sqlx official docs (docs.rs)
- realworld-axum-sqlx reference implementation
- Offline Mode in depth — DeepWiki
- Compile-time Query Checking internals — DeepWiki
- GitHub Issue #1096: Per-query build time overhead discussion
- SQLx Compile Time Woes analysis — Cosmic Horror
- sqlx-cli README (prepare / --check usage)
- diesel-async crate