Overview
Three philosophies split the Rust relational world. sqlx says write SQL — and verifies every query against a live database at compile time, mapping rows to structs by derive. diesel says don't write SQL — its Rust DSL makes invalid queries unrepresentable, checked against a schema snapshot, no database needed at build time. sea-orm says you want an ORM — entities, ActiveModel mutations, relations, async on top of sqlx's drivers. Underneath sit the raw drivers (tokio-postgres, rusqlite) when you want the wire protocol and nothing else. The ecosystem's center of gravity is sqlx, and its checked-SQL move is the genuinely novel contribution — the position Go's sqlc approximates offline and JPA doesn't attempt.
Key points
- sqlx:
query!/query_as!macros connect toDATABASE_URLat build time, ask the database to prepare the statement, and typecheck columns/parameters against your structs — SQL typos and schema drift become compile errors. Async, works with Postgres/MySQL/SQLite, ships its own pool. Offline mode (cargo sqlx prepare→.sqlx/metadata, committed) keeps CI database-free. - The sqlx caveats: dynamic SQL falls back to unchecked
query()(or the sea-query builder); the macros' DB connection requirement surprises new contributors; nullability inference occasionally needsas "col!"overrides. - diesel: the mature heavyweight —
schema.rsgenerated by diesel CLI, queries as composable typed expressions, excellent performance, sync by design (diesel-async exists). Its compile errors are legendarily verbose; its guarantees are the strongest of the three. - sea-orm: entity/ActiveModel codegen from the database, relations, pagination, mock-testing support — the closest thing to a JVM-style ORM experience, chosen by teams that want conventions over SQL; built on sqlx so it inherits the async pool.
- tokio-postgres when raw: pipelined, fast,
LISTEN/NOTIFYsupport; pool with deadpool-postgres/bb8; rusqlite is the SQLite equivalent (sync, embedded). - Transactions:
let mut tx = pool.begin().await?→ pass&mut *txto queries →tx.commit().await?; dropping the value rolls back — cleanup-by-RAII making the forgotten-rollback bug structurally impossible. - Migrations: sqlx's embedded migrator (
sqlx migrate+migrate!()at startup), refinery standalone, diesel's own — plain SQL files by convention;#[sqlx::test]runs them per test database. - Choosing: sqlx by default; diesel when maximum compile-time safety on complex queries pays; sea-orm when the team wants entity conventions; raw driver for pipelining-critical paths and LISTEN/NOTIFY plumbing.
Examples
#[derive(sqlx::FromRow)]
struct User { id: Uuid, email: String, created_at: OffsetDateTime }
async fn find(pool: &PgPool, id: Uuid) -> sqlx::Result<Option<User>> {
sqlx::query_as!(User,
r#"SELECT id, email, created_at FROM users WHERE id = $1"#, id)
.fetch_optional(pool)
.await
}
async fn transfer(pool: &PgPool, from: Uuid, to: Uuid, cents: i64) -> sqlx::Result<()> {
let mut tx = pool.begin().await?;
sqlx::query!("UPDATE accounts SET balance = balance - $1 WHERE id = $2", cents, from)
.execute(&mut *tx).await?;
sqlx::query!("UPDATE accounts SET balance = balance + $1 WHERE id = $2", cents, to)
.execute(&mut *tx).await?;
tx.commit().await? // drop without commit = rollback
}
Related
- Storage access from Rust — the map — parent map.
- Integration testing —
#[sqlx::test]and testcontainers against real engines. - Web security — parameterization by construction: why SQL injection is nearly extinct on this stack.
- Databases and other storage systems — the engine-side internals.
- Relational databases from Java — the JDBC/JPA managed-layers contrast.
- Relational databases from Go — sqlc's offline codegen: the nearest cousin to sqlx's checked SQL.