Database Access
Database access is where Rust's type system earns its keep in a backend service. The three main options — sqlx, SeaORM, and Diesel — take fundamentally different positions on the tradeoff between query expressiveness, compile-time safety, and ORM abstraction. Choosing well means fewer bugs in production; choosing poorly means fighting the library on every non-trivial query.
The Three Options at a Glance
- sqlx: async-first, raw SQL with compile-time query verification against a live database
- Diesel: synchronous (async in progress), DSL-based query builder, strongest compile-time guarantees
- SeaORM: async ORM with ActiveRecord-style API, built on sqlx under the hood
sqlx: SQL You Actually Write
sqlx lets you write real SQL and verifies queries against a real database schema at compile time using macros.
use sqlx::PgPool;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, sqlx::FromRow)]
struct User {
id: i64,
email: String,
name: String,
created_at: chrono::DateTime<chrono::Utc>,
}
async fn get_user(pool: &PgPool, id: i64) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as!(
User,
"SELECT id, email, name, created_at FROM users WHERE id = $1",
id
)
.fetch_optional(pool)
.await
}
async fn list_active_users(pool: &PgPool, limit: i64) -> Result<Vec<User>, sqlx::Error> {
sqlx::query_as!(
User,
r#"
SELECT id, email, name, created_at
FROM users
WHERE deleted_at IS NULL
ORDER BY created_at DESC
LIMIT $1
"#,
limit
)
.fetch_all(pool)
.await
}The query_as! macro connects to the database at compile time (via DATABASE_URL in .env) and checks that:
- The SQL is valid syntax
- Column names match struct fields
- Column types are compatible with the field types
This means a schema migration that renames a column breaks the build immediately, not at runtime. For teams that have been burned by SQL-in-strings bugs, this is significant.
sqlx Connection Pooling
use sqlx::postgres::PgPoolOptions;
async fn create_pool(database_url: &str) -> Result<PgPool, sqlx::Error> {
PgPoolOptions::new()
.max_connections(20)
.min_connections(2)
.acquire_timeout(std::time::Duration::from_secs(5))
.connect(database_url)
.await
}sqlx Transactions
async fn transfer_balance(
pool: &PgPool,
from_id: i64,
to_id: i64,
amount: i64,
) -> Result<(), sqlx::Error> {
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
}Diesel: The Type-Safe DSL
Diesel generates a Rust DSL from your schema that prevents whole categories of SQL errors at compile time — joins on mismatched types, selecting columns from the wrong table, and so on.
use diesel::prelude::*;
use crate::schema::users;
#[derive(Queryable, Selectable, Debug)]
#[diesel(table_name = users)]
struct User {
id: i64,
email: String,
name: String,
}
#[derive(Insertable)]
#[diesel(table_name = users)]
struct NewUser<'a> {
email: &'a str,
name: &'a str,
}
fn get_user(conn: &mut PgConnection, user_id: i64) -> QueryResult<Option<User>> {
users::table
.filter(users::id.eq(user_id))
.select(User::as_select())
.first(conn)
.optional()
}
fn create_user(conn: &mut PgConnection, email: &str, name: &str) -> QueryResult<User> {
let new_user = NewUser { email, name };
diesel::insert_into(users::table)
.values(&new_user)
.returning(User::as_returning())
.get_result(conn)
}Diesel's DSL is expressive for CRUD and most standard queries. It becomes unwieldy for complex analytical queries, CTEs, or window functions — you end up dropping to raw SQL anyway, which negates some of the DSL benefit.
The sync-first design means you need a connection pool that handles thread-blocking operations. In an async service, this typically means spawn_blocking or a library like deadpool-diesel.
SeaORM: The Async ORM
SeaORM targets engineers who want ORM conveniences (entities, relations, migrations via SeaMigration) with async-native execution.
use sea_orm::*;
use entity::users;
async fn get_user(db: &DatabaseConnection, id: i64) -> Result<Option<users::Model>, DbErr> {
users::Entity::find_by_id(id).one(db).await
}
async fn create_user(
db: &DatabaseConnection,
email: String,
name: String,
) -> Result<users::Model, DbErr> {
let user = users::ActiveModel {
email: Set(email),
name: Set(name),
..Default::default()
};
user.insert(db).await
}
async fn find_active_users(db: &DatabaseConnection) -> Result<Vec<users::Model>, DbErr> {
users::Entity::find()
.filter(users::Column::DeletedAt.is_null())
.order_by_desc(users::Column::CreatedAt)
.limit(20)
.all(db)
.await
}SeaORM generates entity structs from your database schema (or from migrations). Relations, eager loading, and pagination are first-class. The tradeoff: the abstraction layer adds indirection when you need to debug a slow query or write something the ORM does not support cleanly.
Decision Framework
| Criterion | sqlx | Diesel | SeaORM |
|---|---|---|---|
| Async native | Yes | Partial | Yes |
| Write raw SQL | Yes | Optional | Optional |
| Compile-time checks | Yes (macro) | Yes (DSL) | Partial |
| ORM relations | No | Yes | Yes |
| Migration tool | sqlx-cli | diesel-cli | sea-migrate |
| Complex queries | Easy | Awkward | Awkward |
Migrations
All three have migration tooling. sqlx-cli is the most common choice even for SeaORM users because it is simple and integrates with the same connection infrastructure.
# Create a migration
sqlx migrate add create_users_table
# Run pending migrations
sqlx migrate run
# Check migration status
sqlx migrate infoMigration files are plain SQL, version-controlled with your application. This is the right default — migrations are too important to hide inside a framework's own format.
Key Takeaways
- sqlx gives you the best of both worlds: real SQL you control, verified at compile time against the actual schema — strongly recommended for most new services.
- Diesel's type DSL provides the strongest compile-time guarantees but becomes a liability for complex or analytical queries that don't fit the DSL cleanly.
- SeaORM suits teams who want ActiveRecord-style ergonomics (entities, relations, eager loading) and are willing to trade some query transparency for convenience.
- The sqlx
query_as!macro catches schema drift at compile time — a renamed column breaks the build, not production. - Use sqlx-cli for migrations regardless of which library you choose for queries; plain SQL migrations are the most portable and auditable choice.
- In an async service using Diesel, always run queries via
spawn_blockingto avoid blocking Tokio worker threads.