Skip to main content
Rust for Backend Engineers

Database Access

Ravinder··6 min read
RustBackendDatabasesqlxSeaORMDiesel
Share:
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

quadrantChart title Rust Database Library Tradeoffs x-axis "SQL Explicit" --> "ORM Abstracted" y-axis "Fewer Compile-Time Checks" --> "More Compile-Time Checks" quadrant-1 Safe + Abstracted quadrant-2 Safe + Explicit quadrant-3 Risky + Explicit quadrant-4 Risky + Abstracted sqlx: [0.20, 0.85] Diesel: [0.30, 0.90] SeaORM: [0.75, 0.65]
  • 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

flowchart TD A[Choosing a database library] --> B{Is async required?} B -- Yes --> C{Do you want to write SQL directly?} B -- No --> D[Diesel sync is fine] C -- Yes --> E[sqlx] C -- No --> F{Do you want ORM patterns and migrations?} F -- Yes --> G[SeaORM] F -- No --> E D --> H{Complex domain with many relations?} H -- Yes --> I[Consider Diesel with schema DSL] H -- No --> J[sqlx with spawn_blocking]
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 info

Migration 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_blocking to avoid blocking Tokio worker threads.
Share: