Talking to Databases from Rust
Every Rust service that talks to a database is really making three separate decisions at once, even if the code only shows one line of Cargo.toml. It is choosing a runtime model (does a query block a thread or yield a task), a query-safety strategy (does the compiler check a SQL string against a live schema, or does it check typed Rust expressions instead), and a connection lifecycle policy (how connections are opened, shared, and recycled). Each of these axes has genuine trade-offs rather than a single "correct" answer, and the ecosystem's split between sqlx, Diesel, and SeaORM largely maps onto where each crate lands on the first two axes.
Understanding this as three orthogonal choices, rather than as "which crate is best," is the mental model this page builds. The rest of this section's pages - sqlx, Diesel, SeaORM, migrations, connection pooling, transactions - are all instances of decisions made along these three axes.
Summary
- Rust database access is a stack of decisions - runtime model (async vs. sync), query-safety strategy (compile-time-checked SQL vs. ORM abstraction), and connection lifecycle (pooling) - not a single library choice.
- Insight: Getting any one axis wrong does not just create style friction. It produces bug classes - blocked async runtimes, schema drift, exhausted connection pools - that only surface under real load.
- Key Concepts: async driver, compile-time query checking, ORM, query builder, connection pool, migration.
- When to Use: Designing a new service's data layer, choosing between sqlx, Diesel, and SeaORM, or diagnosing pool exhaustion and N+1 query problems.
- Limitations/Trade-offs: None of the three axes has a universally correct answer. The right combination depends on team size, how often the schema changes, and how much raw SQL control the team wants to keep.
- Related Topics: connection pooling, ORMs, async runtimes, schema migrations.
Foundations
A database driver in Rust is a crate that implements the wire protocol a database server speaks - the byte-level conversation for authentication, query submission, and row streaming. sqlx, tokio-postgres, and Diesel's pg backend all implement this conversation for PostgreSQL, just with different layers built on top.
The first fork in the road is sync versus async. A synchronous driver blocks the calling thread for the full round trip of a query: the thread sends bytes, then sits idle until the server replies. An asynchronous driver instead registers interest in the socket and yields control back to the runtime, so the same OS thread can make progress on other work while the query is in flight.
A useful analogy is a single cashier versus a short-order cook. A sync driver is a cashier who stops serving anyone else until the current customer's order is fully rung up. An async driver is a cook who starts an order, moves to the next ticket while it grills, and comes back when it is ready. Under low concurrency both finish in about the same wall-clock time. Under high concurrency the cook-style model keeps far more customers moving at once.
This is why sqlx is async-native (built for Tokio from the start) while Diesel is fundamentally synchronous, with diesel-async as an addon rather than the default. SeaORM sits async-native like sqlx, but adds an entity-and-relation layer on top, closer in spirit to Diesel's ergonomics.
The second fork is how the compiler helps you avoid bad queries, and that split deserves its own section because it is the one most often misunderstood.
Mechanics & Interactions
sqlx's query! and query_as! macros perform compile-time query checking: at build time, they connect to a live database (or read a cached .sqlx/ metadata directory generated by cargo sqlx prepare) and ask the server to validate the exact SQL string you wrote against the real schema. If a column does not exist or a type mismatches, the build fails. Crucially, this checks a string, not a Rust value - the SQL itself is still hand-written text.
Diesel takes the opposite approach: an ORM-style abstraction. Its table! macro (or diesel print-schema codegen) generates Rust types that mirror your schema, and every query is built by composing typed Rust method calls - .filter(...), .select(...), .order(...) - rather than writing SQL text at all. The compiler cannot catch a schema mismatch it never sees as SQL; instead, it structurally prevents you from expressing certain invalid queries in the first place, because there is no Rust expression that would produce them.
// sqlx: checks this exact SQL string against the live/cached schema at build time
let row = sqlx::query!("SELECT id, email FROM users WHERE id = $1", id)
.fetch_one(&pool)
.await?;
// Diesel: no SQL string exists - the query is a typed Rust expression
let row = users::table.filter(users::id.eq(id)).first::<User>(&mut conn)?;The sqlx path fails at build time if the column list drifts from the schema; the Diesel path fails at build time if the Rust expression cannot type-check against the generated schema module. Both catch drift before runtime, through genuinely different mechanisms - one validates text against a server, the other eliminates invalid shapes from the type system.
SeaORM occupies a middle position: it is async and entity-based like a lighter Diesel, generating typed models from the schema, but it renders SQL dynamically at runtime through a query builder rather than Diesel's compile-time DSL, trading a little of Diesel's compile-time guarantee for friendlier handling of relations and joins.
Underneath either choice, the actual data flow is the same shape: a query is handed to a connection pool, the pool lends out an already-established connection, the driver serializes the query and its bound parameters over the wire protocol, the server executes and streams rows back, and the driver deserializes those rows into Rust structs (via FromRow in sqlx, Queryable in Diesel). The pool step is where connection lifecycle - the third axis - enters the picture.
Advanced Considerations & Applications
A connection pool exists because establishing a new database connection is disproportionately expensive relative to running a simple query - it involves a TCP handshake, TLS negotiation, and authentication, all before the first SELECT runs. A pool amortizes that cost by keeping a bounded set of already-authenticated connections alive and lending them out, so a request path only pays the connection cost once at startup rather than on every query.
This makes pool sizing a genuine capacity-planning problem, not a knob to maximize. A common rule of thumb is instances × pool_max ≤ database_max_connections × 0.7-0.8, leaving headroom for migrations, admin sessions, and connections from other services sharing the same database. Oversizing every service's pool "to be safe" is a common way to accidentally exhaust the database's own connection ceiling faster than a small pool ever would.
Pool exhaustion and slow queries produce the same symptom from the outside - requests hang - but need different diagnoses. A slow query means the database itself is doing too much work per call; a starved pool means requests are queued waiting for a connection that never frees up because every existing connection is busy (or leaked by a long-held transaction). Setting an explicit acquire_timeout turns a silent hang into a visible, retryable error, which is the difference between a debuggable 503 and an incident that looks like the whole service froze.
Schema drift is the failure mode that undermines both compile-time strategies equally. sqlx's query! checks are only as current as the last cargo sqlx prepare run; Diesel's schema.rs is only as current as the last diesel print-schema. Neither approach protects you from a migration that ran in production but was never regenerated locally - which is why migrations belong in the same mental model as query safety, not as a separate concern.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
| sqlx (compile-time-checked SQL) | Full SQL control, async-native, checks real queries against a real schema | Requires a live DB or prepared cache at build time; SQL is still hand-written text | Teams comfortable writing SQL who want async and guardrails without an ORM |
| Diesel (query-builder ORM) | Structurally prevents whole classes of invalid queries; strong compile-time guarantees | Historically sync-first; async via diesel-async addon; steeper learning curve | Schema-heavy domains where correctness matters more than raw SQL flexibility |
| SeaORM (async ORM) | Async-native with relation/entity ergonomics closer to a traditional ORM | Runtime-built SQL trades some of Diesel's compile-time guarantee for convenience | Teams that want ORM relation handling without giving up async |
Common Misconceptions
- "Async drivers are always faster." - Async wins under I/O-bound concurrency, where many queries are in flight at once and threads would otherwise sit idle. A single query on a mostly idle service sees little to no latency difference between sync and async.
- "sqlx's compile-time checking makes it as safe as an ORM." - It validates that a SQL string is well-formed against the schema, not that the query is semantically correct. Diesel structurally rules out certain invalid queries; sqlx only catches ones that fail against the real schema.
- "A bigger connection pool is always safer." - Every process's pool draws from the same finite database connection limit. Oversized pools across several service instances can exhaust that limit faster than undersized ones ever would.
- "An ORM means you never need to think about SQL." - N+1 query patterns, slow generated joins, and edge cases that need a raw-SQL escape hatch show up in every ORM eventually.
- "One connection pool per request is more isolated and therefore safer." - It defeats the entire purpose of pooling and re-introduces the per-request connection cost the pool exists to avoid. One pool per process, shared across requests, is the standard pattern.
FAQs
What's actually different between a driver and an ORM?
A driver speaks the database's wire protocol and hands back rows or errors. An ORM sits on top of a driver and adds a layer that maps Rust types to tables and generates SQL from typed method calls, so you rarely write raw SQL by hand.
Why does sqlx need a live database connection at build time?
Its query! macro validates the exact SQL string against the real schema to catch typos and type mismatches before runtime. When a live database is not available (like in CI), cargo sqlx prepare caches that metadata in a .sqlx/ directory so builds can check against the cache instead.
Is Diesel really "safer" than sqlx?
They catch different things. Diesel's typed query builder cannot express certain invalid queries at all, which is a stronger guarantee in principle. sqlx validates the SQL text you actually wrote against a real schema snapshot, which catches drift just as effectively in practice but through a different mechanism.
How does an async driver avoid blocking the runtime?
It registers interest in the socket's readiness with the async runtime and returns control at each .await point, instead of parking the OS thread until the server responds. The runtime can then run other tasks on that same thread while the query is in flight.
Why can't I just open a new connection per request?
Each new connection pays for a TCP handshake, TLS negotiation, and authentication before the first query even runs. Under load this cost dominates, and it also multiplies quickly against the database's own connection ceiling.
What does a connection pool track under the hood?
A bounded set of live, already-authenticated connections, plus a queue of tasks waiting for one to free up when the pool is fully checked out. Configuration like max_connections, min_connections, and acquire_timeout controls that behavior.
When would I reach for SeaORM instead of Diesel or sqlx directly?
When you want async-native access with entity/relation ergonomics closer to a traditional ORM, and you are willing to trade some of Diesel's compile-time guarantees for more convenient handling of joins and related records.
Does compile-time query checking replace integration tests?
No. It catches schema mismatches and typos in the SQL text, but it says nothing about whether the query returns the business-correct result. Logic errors still need real tests against real data.
What happens if a query passes its compile-time check but is still wrong?
It ships. Compile-time checks only prove the SQL is valid against the schema at build time - they cannot verify intent, like a WHERE clause that is missing a condition but is still syntactically and type-correct.
How do I decide how big a connection pool should be?
Start from the database's max_connections limit, divide by the number of service instances that will share it, and leave roughly 20-30% headroom for migrations and admin sessions. Then load-test until you see acquire timeouts - that is your real ceiling.
Can I mix sync and async database access in the same service?
Yes, but a sync call inside an async handler must be moved off the async runtime's worker threads (with something like spawn_blocking), or it will stall every other task sharing that thread.
Why do migrations matter to type safety at all?
Both sqlx's cached query metadata and Diesel's generated schema module are snapshots of the database at the moment they were last regenerated. If a migration runs in production without regenerating that snapshot locally, the compiler is checking against a schema that no longer exists.
Related
- Databases Basics - hands-on walkthrough of pools and queries
- sqlx - compile-time-checked async SQL in depth
- Diesel - the type-safe query-builder ORM
- SeaORM - the async ORM alternative
- Connection Pooling - sizing and lifecycle in depth
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.