How Rust's `sqlx` macros pull SQL errors into compile time — `query!` vs `query_as!` and offline mode
If you've worked on backends long enough, you've probably seen this a few times: an API that worked perfectly in staging suddenly spits out column "user_namee" does not exist in production. It doesn't matter whether you're using TypeScript's Prisma or TypeORM, Go's database/sql, or Python's SQLAlchemy — DB errors that can't be caught until runtime clearly exist. When you change a schema and forget to update the entity file, or make a typo in a column name inside a query, you always find out after deployment.
Rust's sqlx takes a fundamentally different approach to this problem. Its core design philosophy is validating query correctness at cargo build time rather than at runtime. Column typos, type mismatches, references to non-existent columns — all of these become build failures. And you still write plain SQL without any ORM. If you come from a TypeScript background, you'll recall how unsettling it was when things covered by any blew up at runtime; if you come from Go or Python, you'll remember the anxiety of typos that went undetected without integration tests.
This article covers the difference in type inference between the query! and query_as! macros, which one to use in which real-world situations, and how to configure offline mode for building without a DB in CI environments. This is based on sqlx 0.9.0, released in May 2026.
How Compile-Time Verification Actually Works
What the Macro Does During cargo build
When I first saw the query! macro, I honestly thought, "How can this catch DB errors at compile time?" The mechanism is more intuitive than you'd expect.
When the macro expands, it connects to the DB using the DATABASE_URL environment variable and then only prepares the query via PostgreSQL's extended query protocol (Parse message). No actual data is read or written — it only collects metadata like column names, types, and nullability that the DB returns. This metadata is converted into Rust types, and if there's a problem, compilation stops right there.
The concept is similar to runtime schema validation with zod, but the critical difference is that it finishes before execution.
What Changes Compared to Other Languages
Think about what happens when you make a typo in a column name with TypeORM or SQLAlchemy: you had to start the dev server, call the endpoint directly, or run tests to find out. With sqlx, a single cargo build is all it takes.
error: column `user_namee` does not exist
--> src/handlers/user.rs:15:5There's also a difference from an SQL injection standpoint. The macro structure enforces parameter binding ($1, $2), so within the macro API, string interpolation is blocked at the API design level itself. If you use an API that concatenates strings — like QueryBuilder, which we'll cover later — that's a different story, but for query!/query_as!, it's structurally prevented, not just "recommended against."
query! vs query_as! — What to Use and When
To state the decision criterion in one line upfront: use query_as! when the result type needs to be exposed in a function signature or reused in multiple places; use query! as the default for everything else. Details follow.
query! — For Locally Consumed Queries
query! returns results as an anonymous struct. Column names become field names, and DB types are automatically inferred as Rust types. The advantage is that you can use it immediately without defining a separate struct.
let row = sqlx::query!("SELECT id, name FROM users WHERE id = $1", user_id)
.fetch_one(&pool)
.await?;
println!("{}", row.name);INSERT with PostgreSQL's RETURNING is also clean.
let rec = sqlx::query!(
"INSERT INTO posts (title, body) VALUES ($1, $2) RETURNING id",
title, body
)
.fetch_one(&pool)
.await?;
println!("Created ID: {}", rec.id);There's one constraint that trips people up frequently: the SQL passed to the macro must be a string literal. Storing it in a const constant or a variable and passing that will produce an expected string literal error. This is because the macro needs to parse the SQL text directly in order to query the DB at compile time — it's the most common stumbling block for first-time users.
query_as! — Where You Need a Reusable Type
query_as! maps results to an explicitly defined Rust struct. This is the better choice when you use the same query result in multiple places or want to manage API response types clearly.
#[derive(sqlx::FromRow)]
struct User {
id: i64,
name: String,
email: Option<String>,
}
let user = sqlx::query_as!(User, "SELECT id, name, email FROM users WHERE id = $1", user_id)
.fetch_one(&pool)
.await?;Columns defined as nullable in the DB must be declared as Option<T>. If you omit this, sqlx's FromRow derive macro detects the type mismatch and raises an error at macro expansion (to be precise, it's not rustc catching it directly — rather, the code generated by the macro fails to compile). The error message tells you which field was found to be nullable, so fixing it isn't hard.
Complex JOIN results are handled naturally as well.
#[derive(sqlx::FromRow)]
struct OrderSummary {
order_id: i64,
user_name: String,
total: f64,
}
let orders = sqlx::query_as!(
OrderSummary,
r#"SELECT o.id as order_id, u.name as user_name, o.total
FROM orders o JOIN users u ON o.user_id = u.id
WHERE o.status = $1"#,
"pending"
)
.fetch_all(&pool)
.await?;Type Overrides — The "col: _" Syntax
When type inference is ambiguous, you can use the "col: _" syntax in query_as! to use the struct field type as-is. The _ placeholder means "use the type I defined for the struct field."
sqlx::query_as!(MyStruct, r#"SELECT id as "id: _" FROM users"#)In query!, you specify the type explicitly.
sqlx::query!(r#"SELECT id as "id: uuid::Uuid" FROM users"#)Decision Flow for Choosing Between Them
Comparison Summary
| Item | query! |
query_as! |
|---|---|---|
| Return type | Anonymous struct (macro-generated) | Named struct |
| Struct pre-definition | Not required | Required (#[derive(FromRow)]) |
| Function signature exposure | Inconvenient (anonymous type) | Natural |
| Reusability | Low | High |
| Type override | r#"SELECT id as "id: uuid::Uuid" ..."# |
r#"SELECT id as "id: _" ..."# |
| Nullable handling | Automatically reflected from DB metadata | Struct field Option<T> declaration required |
| Suitable for | Local consumption, prototyping | API response types, reusable query results |
Code for Real-World Scenarios
snake_case ↔ DB Column Name Conversion
If you're working on a legacy project where DB columns are designed in camelCase, the rename_all attribute is useful.
#[derive(sqlx::FromRow)]
#[sqlx(rename_all = "camelCase")]
struct ApiResponse {
user_id: i64,
created_at: chrono::DateTime<chrono::Utc>,
}UUID, DateTime, and JSONB Type Integration
sqlx integrates directly with the uuid, chrono, and serde_json crates. Enable the feature flags in Cargo.toml to map DB columns directly to Rust types.
[dependencies]
sqlx = { version = "0.9", features = ["postgres", "runtime-tokio", "uuid", "chrono", "json"] }
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }#[derive(sqlx::FromRow)]
struct Event {
id: uuid::Uuid,
payload: serde_json::Value,
created_at: chrono::DateTime<chrono::Utc>,
}
let event = sqlx::query_as!(
Event,
"SELECT id, payload, created_at FROM events WHERE id = $1",
event_id
)
.fetch_one(&pool)
.await?;Offline Mode — CI Builds Without a DB
The biggest practical downside of compile-time verification is that the build fails entirely without a DATABASE_URL. There are situations — onboarding new team members or CI pipelines — where you can't spin up a DB every time. Offline mode solves this.
How It Works
Running cargo sqlx prepare generates per-query metadata JSON files in the .sqlx/ directory. If you commit these files to git, CI can build with just SQLX_OFFLINE=true set, without a DB connection.
Step-by-Step Setup
# 1. Install sqlx-cli
cargo install sqlx-cli --no-default-features --features postgres
# 2. Generate metadata in your local environment where a DB is connected
cargo sqlx prepare
# For an entire workspace
cargo sqlx prepare --workspace
# 3. Commit the generated files to git
git add .sqlx/
git commit -m "chore: update sqlx prepared queries"GitHub Actions Configuration
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: password
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Rust build cache
uses: Swatinem/rust-cache@v2
- name: Install sqlx-cli
run: cargo install sqlx-cli --no-default-features --features postgres --locked
- name: Build (offline mode)
env:
SQLX_OFFLINE: true
run: cargo build --release
- name: Verify prepare files are up to date
env:
DATABASE_URL: postgres://postgres:password@localhost:5432/testdb
run: cargo sqlx prepare --checkThe ports: - 5432:5432 mapping is required to access the service container from the host runner via localhost. Without it, the connection will fail at the --check step. Using Swatinem/rust-cache to cache ~/.cargo and build artifacts also reduces the time spent rebuilding sqlx-cli on every run.
The --check flag verifies that the current .sqlx/ files match the actual DB schema. If you modify a query without re-running cargo sqlx prepare, this step will fail — I've forgotten this once or twice and turned CI red myself.
Trade-offs and Common Pitfalls
Pros and Cons at a Glance
| Item | Details |
|---|---|
| Compile-time verification | Pulls runtime DB errors up to build time |
| Plain SQL | Use PostgreSQL-specific features like RETURNING, WITH, LATERAL directly, without ORM DSL |
| Async support | Supports both Tokio and async-std, with built-in connection pooling |
| SQL injection prevention | Parameter binding enforced within the macro API |
| Dynamic query difficulty | QueryBuilder required when WHERE clauses are determined at runtime; macro-level verification is forfeited |
| No relationship support | No eager/lazy loading; N+1 problems must be managed manually |
| Offline file management | Must re-run cargo sqlx prepare whenever a query changes |
| Version stability | 0.9.x as of August 2026; below 1.0, so breaking changes are possible |
Easy-to-Miss Pitfalls
Do not put SQLX_OFFLINE=true in .env
This is a bug reported as an actual issue (issue #3836). Setting SQLX_OFFLINE=true in a .env file causes the cargo sqlx prepare command itself to run in offline mode, so metadata won't be updated. If you want to keep offline mode on permanently in your local environment, use one of the following instead.
- Add
export SQLX_OFFLINE=trueto your shell profile (~/.zshrc,~/.bashrc, etc.) - Add an
[env]section to.cargo/config.tomlat your project root
# .cargo/config.toml
[env]
SQLX_OFFLINE = "true"In CI, inject it as a job-level environment variable.
Forgetting to re-run cargo sqlx prepare
If you modify a query but don't update the .sqlx/ files, the build will pass locally but fail CI's --check. It's worth enforcing this with a pre-commit hook on the team, or adding it to your PR checklist.
Passing non-literal values to query!
As mentioned earlier, SQL must always be a literal. If you have a habit of reusing const SQL: &str = "..." constants, that won't work in sqlx macros. If you need reuse, wrapping in a function is the natural approach.
Declaring nullable columns without Option<T>
If you declare a DB-nullable column as String, the macro-generated code won't compile. The error messages may look unfamiliar at first, but they clearly indicate which field's mapping is mismatched.
Trying to use macros for dynamic queries
For dynamic queries — like a search API where filter conditions are determined at runtime — you need sqlx::QueryBuilder. Values must always be inserted with push_bind; concatenating user input directly with push introduces SQL injection risk. In other words, the macro's enforced safety disappears in this domain, and the developer must be explicitly careful.
Criteria for Choosing ORM Alternatives
Compared to other DB libraries in the Rust ecosystem, the breakdown looks roughly like this:
- If you need high-level abstraction and an ActiveModel-style query builder → SeaORM (provides an Active Record-style API through
ActiveModel; though its internal architecture is closer to Data Mapper) - If you need a strongly-typed DSL and a synchronous query builder → Diesel
- If you want to keep SQL control while having compile-time verification → sqlx
For accurate download counts and adoption comparisons, checking crates.io directly is most reliable. Third-party comparison articles vary in time and aggregation criteria, making them awkward to cite directly.
What Changed in sqlx 0.9.0
There are notable changes in 0.9.0, released May 21, 2026.
A sqlx.toml configuration file was added. In multi-DB or multi-tenant environments, you can now manage per-DB settings and global type overrides in one file. The hassle of overriding custom types or third-party crate integrations on every query has been reduced. Since the specific schema and key names are still being refined with each release, be sure to consult the official release notes and the example sqlx.toml in the repository before applying it, and copy from there directly (the reason I haven't included example TOML inline is that copying an incorrect format and hitting a parse error is the most common early mishap).
Beyond this, SQLite now supports compile-time extension loading, and in 0.8.x, a bug related to sqlx-cli's automatic .env recognition was fixed, along with the addition of a --no-dotenv option.
What You Actually Encounter After Adoption
Let me close with a few things we repeatedly ran into as a team after switching to sqlx.
First, trust in the CI pipeline improves dramatically. Previously, cases where "the build passed but a typo blew up on the production DB" would arrive as an alert 15 minutes after deployment. After adopting sqlx, that category of error gets caught as a compile failure at the PR stage. On the flip side, spinning up a service container on the CI runner is a burden, so we often use a structure where offline mode and --check are separated into distinct jobs.
Second, development velocity actually slows down at first. The workflow of running cargo sqlx prepare and committing .sqlx/ every time you change a schema feels unfamiliar, and hitting the query! string literal constraint or nullable mapping errors triggers a "TypeORM just let this through" pushback within the team. What helped us get through that period was very practical tooling: attaching a checklist to migration PRs that explicitly mentions running cargo sqlx prepare, and sharing a pre-commit hook.
Third, it's good to identify upfront which parts of your codebase actually need dynamic queries. If you try to force-fit a search API with an exploding combination of filters into macros, the code gets messy quickly. The practical balance we found was to isolate just those areas into QueryBuilder and keep everything else on macros.
References
- launchbadge/sqlx — GitHub
- sqlx::query! macro documentation — docs.rs
- sqlx::query_as! macro documentation — docs.rs
- FromRow trait — docs.rs
- Compile-time Query Checking — DeepWiki
- Offline Mode & sqlx prepare — DeepWiki
- CI/CD Workflow with sqlx — DeepWiki
- Raw SQL in Rust with SQLx — Shuttle.dev
- Unraveling sqlx Macros — Leapcell Blog
- SQLx 0.9.0 release announcement — GitHub Discussions
- SQLX_OFFLINE .env bug issue #3836
- Swatinem/rust-cache — GitHub Actions