rustdatabaseormbackend

Diesel, SQLx, SeaORM or Rusqlite? Choosing a Rust ORM in 2026

ยท33 min read

Diesel, SQLx, SeaORM or Rusqlite? Choosing a Rust ORM in 2026

Rust doesn't have one default database library the way Rails has ActiveRecord or Django has its ORM. It has four serious options, and they disagree about almost everything: whether you write SQL or a DSL, whether checks happen at compile time or at runtime, and whether async is built in.

This guide compares them on the same schema doing the same jobs. Every snippet was compiled and run against PostgreSQL (SQLite for rusqlite) with the versions below, which were the latest releases in September 2026:

  • Diesel 2.3.13 and diesel-async 0.9.2
  • SQLx 0.9.0
  • SeaORM 2.0.3
  • rusqlite 0.40.2

The Short Answer

  • Using SQLite in a CLI, desktop app or embedded tool? Use rusqlite.
  • Want to write plain SQL and have the compiler check it? Use SQLx. It's also the safest default for a new async web backend.
  • Want queries checked at compile time without writing SQL strings? Use Diesel, plus diesel-async if you're on Tokio.
  • Want a full ORM with relations, eager loading, pagination and nested saves? Use SeaORM.

The rest of this post explains why, with code.

Table of Contents

At a Glance

LibraryVersionTypeAsyncDatabasesQuery checks
Diesel2.3.13ORM + query builderNoPostgreSQL, MySQL, SQLiteCompile time (DSL)
diesel-async0.9.2Async Diesel backendYesPostgreSQL, MySQL (SQLite via wrapper)Compile time (DSL)
SQLx0.9.0SQL toolkit, not ORMYesPostgreSQL, MySQL, MariaDB, SQLiteCompile time (against a DB)
SeaORM2.0.3Full async ORMYesPostgreSQL, MySQL, SQLiteTypes at compile time, schema at runtime
rusqlite0.40.2SQLite bindingsNoSQLite onlyRuntime

A few things to know before going further:

  • SQLx is not an ORM. You write SQL. The query! macros check it against a real database while your code compiles.
  • SeaORM is built on SQLx. SeaORM 2.0.3 depends on SQLx 0.9 and SeaQuery 1.0.
  • Diesel's core is synchronous. diesel-async is a separate crate from the Diesel team that adds async connections and pools.
  • rusqlite is SQLite only and synchronous. It's the standard way to use SQLite from Rust.

The Example Schema

All the PostgreSQL examples use the same two tables:

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE
);
CREATE TABLE posts (
    id SERIAL PRIMARY KEY,
    user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    title TEXT NOT NULL,
    body TEXT NOT NULL,
    published BOOLEAN NOT NULL DEFAULT FALSE
);

Every library below does the same jobs on this schema: insert a row, run a filtered query, update, build a query from optional filters, load a user's posts, run a transaction, and apply migrations.


Diesel

Diesel is the oldest Rust ORM (first released in 2015). You describe your schema in Rust, and the type system checks every query against it. If a Diesel query compiles, it refers to columns that exist and compares values of the right types.

It is also what crates.io runs on: the registry pins diesel 2.3.13 and diesel-async 0.9.2.

Setup

cargo add diesel --features postgres
cargo add diesel_migrations --features postgres
cargo install diesel_cli --no-default-features --features postgres

The postgres feature links against libpq, so you need the libpq development package installed (libpq-dev on Debian and Ubuntu, libpq on Homebrew). This catches a lot of people on their first build. diesel-async doesn't need libpq, because it talks to Postgres through tokio-postgres.

Schema

You don't write schema.rs by hand. diesel print-schema reads your database and generates it. This is its exact output for our tables:

// @generated automatically by Diesel CLI.
 
diesel::table! {
    posts (id) {
        id -> Int4,
        user_id -> Int4,
        title -> Text,
        body -> Text,
        published -> Bool,
    }
}
 
diesel::table! {
    users (id) {
        id -> Int4,
        name -> Text,
        email -> Text,
    }
}
 
diesel::joinable!(posts -> users (user_id));
 
diesel::allow_tables_to_appear_in_same_query!(posts, users,);

Models

Models are plain structs with derives. Selectable and check_for_backend make Diesel check that the struct fields match the table columns, which gives much clearer errors when they don't.

use crate::schema::{posts, users};
use diesel::prelude::*;
 
#[derive(Queryable, Selectable, Identifiable, Debug)]
#[diesel(table_name = users)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct User {
    pub id: i32,
    pub name: String,
    pub email: String,
}
 
#[derive(Queryable, Selectable, Identifiable, Associations, Debug)]
#[diesel(belongs_to(User))]
#[diesel(table_name = posts)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct Post {
    pub id: i32,
    pub user_id: i32,
    pub title: String,
    pub body: String,
    pub published: bool,
}
 
#[derive(Insertable)]
#[diesel(table_name = users)]
pub struct NewUser<'a> {
    pub name: &'a str,
    pub email: &'a str,
}
 
#[derive(Insertable)]
#[diesel(table_name = posts)]
pub struct NewPost<'a> {
    pub user_id: i32,
    pub title: &'a str,
    pub body: &'a str,
}

Connecting

use diesel::prelude::*;
 
fn connect(url: &str) -> ConnectionResult<PgConnection> {
    PgConnection::establish(url)
}

For a pool of sync connections, Diesel has an r2d2 feature. For async, see diesel-async below.

Insert and Select

Inserts can return the created row:

fn create_user(
    conn: &mut PgConnection,
    name: &str,
    email: &str,
) -> QueryResult<User> {
    diesel::insert_into(users::table)
        .values(&NewUser { name, email })
        .returning(User::as_returning())
        .get_result(conn)
}

Queries read close to SQL:

fn latest_published(
    conn: &mut PgConnection,
) -> QueryResult<Vec<Post>> {
    posts::table
        .filter(posts::published.eq(true))
        .order(posts::id.desc())
        .limit(10)
        .select(Post::as_select())
        .load(conn)
}

Update

debug_query prints the SQL Diesel is going to run, which is useful when you're learning the DSL:

use diesel::debug_query;
use diesel::pg::Pg;
 
fn publish(
    conn: &mut PgConnection,
    id: i32,
) -> QueryResult<Post> {
    let query = diesel::update(posts::table.find(id))
        .set(posts::published.eq(true));
    println!("{}", debug_query::<Pg, _>(&query));
 
    query.returning(Post::as_returning()).get_result(conn)
}
UPDATE "posts" SET "published" = $1 WHERE ("posts"."id" = $2) -- binds: [true, 1]

Dynamic Queries

Each Diesel query has its own type, so you can't add a .filter() inside an if and assign the result back to the same variable. .into_boxed() erases that type so you can:

fn search_posts(
    conn: &mut PgConnection,
    term: Option<&str>,
    published: Option<bool>,
) -> QueryResult<Vec<Post>> {
    let mut query = posts::table
        .select(Post::as_select())
        .into_boxed();
 
    if let Some(term) = term {
        let pattern = format!("%{term}%");
        query = query.filter(posts::title.ilike(pattern));
    }
    if let Some(published) = published {
        query = query.filter(posts::published.eq(published));
    }
 
    query.order(posts::id).load(conn)
}

This covers optional filters. More involved dynamic queries, such as a varying set of joins, are where Diesel gets hard.

Relations

Diesel has no lazy loading. You load parents, then load children with belonging_to, then group them. That's two queries, and no N+1 problem:

fn users_with_posts(
    conn: &mut PgConnection,
) -> QueryResult<Vec<(User, Vec<Post>)>> {
    let all_users = users::table
        .select(User::as_select())
        .load(conn)?;
 
    let all_posts = Post::belonging_to(&all_users)
        .select(Post::as_select())
        .order(posts::id)
        .load(conn)?;
 
    Ok(all_posts
        .grouped_by(&all_users)
        .into_iter()
        .zip(all_users)
        .map(|(posts, user)| (user, posts))
        .collect())
}

Joins use the joinable! relationship from the schema:

fn titles_with_authors(
    conn: &mut PgConnection,
) -> QueryResult<Vec<(String, String)>> {
    posts::table
        .inner_join(users::table)
        .filter(posts::published.eq(true))
        .select((users::name, posts::title))
        .load(conn)
}

Transactions

Return Ok to commit. Return an error, or let ? return one, and the transaction rolls back:

fn signup_with_post(
    conn: &mut PgConnection,
) -> QueryResult<(User, Post)> {
    conn.transaction(|conn| {
        let user = create_user(conn, "Bob", "bob@example.com")?;
 
        let post = diesel::insert_into(posts::table)
            .values(&NewPost {
                user_id: user.id,
                title: "Hello",
                body: "My first post",
            })
            .returning(Post::as_returning())
            .get_result(conn)?;
 
        Ok((user, post))
    })
}

Migrations

diesel migration generate <name> creates a folder with an up.sql and a down.sql. You can embed them in the binary and run them on startup:

use diesel_migrations::{
    EmbeddedMigrations, MigrationHarness, embed_migrations,
};
 
pub const MIGRATIONS: EmbeddedMigrations =
    embed_migrations!("migrations");
 
fn run_migrations(conn: &mut PgConnection) {
    let applied = conn
        .run_pending_migrations(MIGRATIONS)
        .expect("migrations failed");
    println!("applied {} migration(s)", applied.len());
}

The first run printed applied 1 migration(s) and the second applied 0 migration(s).

What the Compile-Time Checks Look Like

Comparing an integer column to a string fails the build (this example is intentionally broken):

let _ = posts::table
    .filter(posts::id.eq("abc"))
    .load::<(i32, i32, String, String, bool)>(conn);

The first error is clear. But that one mistake produced seven errors in total. The other six (ValidGrouping, AppearsOnTable, QueryId, and so on) all follow from the first. Read the first error and ignore the rest.

Diesel Tradeoffs

  • Good: the strongest compile-time guarantees of any option here, it generates the SQL you'd write by hand, and it's mature and stable.
  • Bad: the sync core, long and noisy type errors, and dynamic queries beyond simple filters are harder than in the other libraries.
  • Watch out for: needing libpq to build, and keeping schema.rs in sync (re-run diesel print-schema after each migration, or let diesel migration run do it).

diesel-async

diesel-async gives Diesel async connections and pools. You keep the same DSL, schema and models; you import diesel_async::RunQueryDsl and add .await.

cargo add diesel --features postgres_backend
cargo add diesel-async --features postgres,bb8
cargo add tokio --features macros,rt-multi-thread

postgres_backend gives you Diesel's Postgres query builder without linking libpq.

Connection Pool

diesel-async supports bb8, deadpool and mobc through features. With bb8:

use diesel::prelude::*;
use diesel_async::pooled_connection::AsyncDieselConnectionManager;
use diesel_async::pooled_connection::bb8::Pool;
use diesel_async::{AsyncConnection, AsyncPgConnection, RunQueryDsl};
 
type DbError = Box<dyn std::error::Error + Send + Sync>;
 
async fn make_pool(url: &str) -> Result<Pool<AsyncPgConnection>, DbError> {
    let manager =
        AsyncDieselConnectionManager::<AsyncPgConnection>::new(url);
    let pool = Pool::builder().max_size(10).build(manager).await?;
    Ok(pool)
}

Get a connection with let mut conn = pool.get().await?;.

Queries

async fn published_posts(
    conn: &mut AsyncPgConnection,
) -> QueryResult<Vec<Post>> {
    posts::table
        .filter(posts::published.eq(true))
        .order(posts::id.desc())
        .select(Post::as_select())
        .load(conn)
        .await
}

Transactions (Changed in 0.9)

Before 0.9 you had to write |conn| async move { ... }.scope_boxed(). In 0.9, transaction takes a native async closure:

async fn signup_with_post(
    conn: &mut AsyncPgConnection,
) -> QueryResult<(User, Post)> {
    conn.transaction(async |conn| {
        let user =
            create_user(conn, "Dana", "dana@example.com").await?;
 
        let post = diesel::insert_into(posts::table)
            .values(&NewPost {
                user_id: user.id,
                title: "Async Diesel",
                body: "Hello from diesel-async",
            })
            .returning(Post::as_returning())
            .get_result(conn)
            .await?;
 
        Ok((user, post))
    })
    .await
}

If you call transaction inline and Rust can't infer the error type, spell it out: conn.transaction::<(), diesel::result::Error, _>(async |conn| { ... }).

Use diesel-async when you want Diesel's compile-time checks in an Axum, Actix or other Tokio app. The Rust team moved crates.io to diesel-async and reported a 10-15% speedup on some endpoints from query pipelining.


SQLx

SQLx is an async SQL toolkit. You write normal SQL strings. The query! family of macros sends each query to your development database while the code compiles, and uses the answer to check the SQL and generate Rust types for the parameters and result columns.

The repository moved from launchbadge/sqlx to transact-rs/sqlx in 2026.

Setup

cargo add sqlx --features runtime-tokio,tls-rustls,postgres
cargo add tokio --features macros,rt-multi-thread

New in 0.9: the combined features like runtime-tokio-rustls are gone. You now pick a runtime (runtime-tokio) and a TLS backend (tls-rustls, tls-native-tls, ...) separately. Macros and migrations are on by default.

The macros read DATABASE_URL from the environment or a .env file while compiling:

DATABASE_URL=postgres://postgres:postgres@localhost:5432/mydb

Connection Pool

use sqlx::postgres::{PgPool, PgPoolOptions};
use std::time::Duration;
 
async fn connect() -> Result<PgPool, sqlx::Error> {
    let url = std::env::var("DATABASE_URL")
        .expect("DATABASE_URL must be set");
 
    PgPoolOptions::new()
        .max_connections(5)
        .acquire_timeout(Duration::from_secs(3))
        .connect(&url)
        .await
}

Checked Queries with query_as!

query_as! maps rows into your struct and checks both the SQL and the field types while compiling:

#[derive(Debug)]
struct Post {
    id: i32,
    user_id: i32,
    title: String,
    body: String,
    published: bool,
}
 
async fn recent_published(
    pool: &PgPool,
    user_id: i32,
) -> Result<Vec<Post>, sqlx::Error> {
    sqlx::query_as!(
        Post,
        "SELECT id, user_id, title, body, published
         FROM posts
         WHERE user_id = $1 AND published
         ORDER BY id DESC
         LIMIT $2",
        user_id,
        10_i64
    )
    .fetch_all(pool)
    .await
}

Postgres treats the LIMIT parameter as BIGINT, so the macro makes you pass an i64. Passing an i32 there is a compile error.

query! returns an anonymous record, and query_scalar! returns a single value:

async fn record_and_count(pool: &PgPool) -> Result<(), sqlx::Error> {
    let row = sqlx::query!(
        "SELECT id, name, email FROM users WHERE email = $1",
        "ferris@rust.dev"
    )
    .fetch_one(pool)
    .await?;
    println!("{} {} <{}>", row.id, row.name, row.email);
 
    let drafts = sqlx::query_scalar!(
        "SELECT COUNT(*) FROM posts WHERE NOT published"
    )
    .fetch_one(pool)
    .await?;
    // COUNT(*) is inferred as nullable: Option<i64>
    println!("drafts: {}", drafts.unwrap_or(0));
    Ok(())
}

SQLx gets nullability from Postgres, and Postgres can't prove COUNT(*) is never null. You can override it with SELECT COUNT(*) AS "count!" to get a plain i64.

Insert and Update

async fn create_post(
    pool: &PgPool,
    user_id: i32,
    title: &str,
    body: &str,
) -> Result<Post, sqlx::Error> {
    sqlx::query_as!(
        Post,
        "INSERT INTO posts (user_id, title, body)
         VALUES ($1, $2, $3)
         RETURNING id, user_id, title, body, published",
        user_id,
        title,
        body
    )
    .fetch_one(pool)
    .await
}
async fn publish(pool: &PgPool, id: i32) -> Result<u64, sqlx::Error> {
    let result = sqlx::query!(
        "UPDATE posts SET published = true WHERE id = $1",
        id
    )
    .execute(pool)
    .await?;
    Ok(result.rows_affected())
}

Dynamic Queries with QueryBuilder

The macros need the SQL text at compile time, so they can't handle queries you assemble at runtime. For those, SQLx has QueryBuilder, which keeps values as bind parameters so user input can't be injected:

use sqlx::{FromRow, Postgres, QueryBuilder};
 
async fn search_posts(
    pool: &PgPool,
    search: Option<&str>,
    published: Option<bool>,
) -> Result<Vec<PostRow>, sqlx::Error> {
    let mut qb: QueryBuilder<Postgres> = QueryBuilder::new(
        "SELECT id, user_id, title, body, published \
         FROM posts WHERE 1 = 1",
    );
    if let Some(s) = search {
        qb.push(" AND title ILIKE ")
            .push_bind(format!("%{s}%"));
    }
    if let Some(p) = published {
        qb.push(" AND published = ").push_bind(p);
    }
    qb.push(" ORDER BY id");
 
    qb.build_query_as::<PostRow>().fetch_all(pool).await
}

PostRow derives FromRow, which maps columns by name at runtime:

use sqlx::FromRow;
 
#[derive(Debug, FromRow)]
struct PostRow {
    id: i32,
    user_id: i32,
    title: String,
    body: String,
    published: bool,
}
 
async fn runtime_query(pool: &PgPool) -> Result<(), sqlx::Error> {
    let posts = sqlx::query_as::<_, PostRow>(
        "SELECT id, user_id, title, body, published
         FROM posts WHERE published = $1",
    )
    .bind(true)
    .fetch_all(pool)
    .await?;
    println!("runtime query_as: {} rows", posts.len());
    Ok(())
}

SQL Injection Guard (New in 0.9)

In 0.9, the runtime query*() functions only accept a &'static str or a string wrapped in AssertSqlSafe. A format!-built query no longer compiles by accident:

error[E0277]: dynamic SQL strings should be audited for possible injections
    = help: the trait `SqlSafeStr` is not implemented for `&std::string::String`
    = note: prefer literal SQL strings with bind parameters or `QueryBuilder` to add dynamic data to a query.
            To bypass this error, manually audit for potential injection vulnerabilities and wrap with `AssertSqlSafe()`.

When you really need a dynamic identifier, check it yourself and wrap it:

use sqlx::AssertSqlSafe;
 
async fn count_rows(
    pool: &PgPool,
    table: &str,
) -> Result<i64, sqlx::Error> {
    // table comes from a fixed allowlist, never user input
    let sql = format!("SELECT COUNT(*) FROM {table}");
    sqlx::query_scalar(AssertSqlSafe(sql))
        .fetch_one(pool)
        .await
}

This is the change you're most likely to hit when upgrading from 0.8.

Joins

There's no relation API. You write the join, and query_as! checks it:

#[derive(Debug)]
struct PostWithAuthor {
    id: i32,
    title: String,
    author: String,
}
 
async fn posts_with_authors(
    pool: &PgPool,
) -> Result<Vec<PostWithAuthor>, sqlx::Error> {
    sqlx::query_as!(
        PostWithAuthor,
        r#"SELECT p.id, p.title, u.name AS author
           FROM posts p
           JOIN users u ON u.id = p.user_id
           WHERE p.published
           ORDER BY p.id"#
    )
    .fetch_all(pool)
    .await
}

Transactions

Pass &mut *tx in place of the pool. If tx is dropped without commit(), it rolls back:

async fn create_user_with_post(
    pool: &PgPool,
) -> Result<i32, sqlx::Error> {
    let mut tx = pool.begin().await?;
 
    let user_id = sqlx::query_scalar!(
        "INSERT INTO users (name, email)
         VALUES ($1, $2) RETURNING id",
        "Corro",
        "corro@rust.dev"
    )
    .fetch_one(&mut *tx)
    .await?;
 
    sqlx::query!(
        "INSERT INTO posts (user_id, title, body)
         VALUES ($1, $2, $3)",
        user_id,
        "Unsafe Rust",
        "Here be dragons"
    )
    .execute(&mut *tx)
    .await?;
 
    tx.commit().await?;
    Ok(user_id)
}

Migrations

Migrations are plain SQL files. Create them with sqlx-cli:

sqlx database create
sqlx migrate add create_users
# Creating migrations/20260925065129_create_users.sql

sqlx::migrate!() embeds the migrations/ folder into your binary. Run it on startup:

sqlx::migrate!().run(&pool).await?;

It records what it applied in a _sqlx_migrations table, so running it again does nothing. Use sqlx migrate add -r <name> if you want separate up and down files.

What the Compile-Time Checks Look Like

A misspelled column fails cargo check, and the error comes from Postgres itself:

Type mismatches are caught too. Binding "one" to an integer parameter gives expected `i32`, found `&str` .

Building Without a Database (CI and Docker)

Needing a database to compile gets in the way in CI and Docker builds. cargo sqlx prepare saves each query's metadata into a .sqlx/ folder, which you commit:

cargo sqlx prepare
# query data written to .sqlx in the current directory;
# please check this into version control
 
env -u DATABASE_URL SQLX_OFFLINE=true cargo build

If a query changes and you forget to re-run prepare, the offline build fails with SQLX_OFFLINE=true but there is no cached data for this query. Add cargo sqlx prepare --check to CI to catch that.

SQLx Tradeoffs

  • Good: you write normal SQL, it's async from the start, its compile-time checks use your real schema, and it's the most downloaded of the four.
  • Bad: no relations, eager loading or change tracking. You write every join and every CRUD statement yourself.
  • Watch out for: keeping .sqlx/ up to date, and the fact that QueryBuilder and runtime query_as are not checked at compile time. Only the macros are.

SeaORM

SeaORM is a full async ORM in the style of ActiveRecord: entities, relations, eager loading, pagination and change tracking. It builds queries with SeaQuery and runs them through SQLx.

SeaORM 2.0 shipped in July 2026. It adds a more compact entity format, typed column constants, an entity loader that avoids N+1 queries, and async-closure transactions. The 1.x API still works, so you can upgrade gradually.

Setup

cargo add sea-orm@2 --features "sqlx-postgres,runtime-tokio-rustls,macros"
cargo add tokio --features full

Entities (2.0 Format)

In 2.0, relations are fields on the model instead of a separate Relation enum:

// src/entities/user.rs
use sea_orm::entity::prelude::*;
 
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "users")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub name: String,
    #[sea_orm(unique)]
    pub email: String,
    #[sea_orm(has_many)]
    pub posts: HasMany<super::post::Entity>,
}
 
impl ActiveModelBehavior for ActiveModel {}
// src/entities/post.rs
use sea_orm::entity::prelude::*;
 
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "posts")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub user_id: i32,
    pub title: String,
    pub body: String,
    pub published: bool,
    #[sea_orm(belongs_to, from = "user_id", to = "id")]
    pub user: BelongsTo<super::user::Entity>,
}
 
impl ActiveModelBehavior for ActiveModel {}

You can generate these from an existing database with sea-orm-cli generate entity. The CLI still produces the old 1.x format by default. Pass --entity-format dense to get the 2.0 format shown above.

Connecting

use sea_orm::{Database, DatabaseConnection, DbErr};
 
const URL: &str =
    "postgres://postgres:postgres@localhost:5432/rust_orms_seaorm";
 
async fn connect() -> Result<DatabaseConnection, DbErr> {
    let db = Database::connect(URL).await?;
    db.ping().await?;
    Ok(db)
}

DatabaseConnection wraps a SQLx pool, so it's cheap to clone and share.

Insert

Set the fields you have and leave the rest NotSet. There's also a builder in 2.0:

use sea_orm::{ActiveModelTrait, ActiveValue::Set};
 
let alice = user::ActiveModel {
    name: Set("Alice".to_owned()),
    email: Set("alice@example.com".to_owned()),
    ..Default::default()
}
.insert(db)
.await?;
println!("inserted: {alice:?}");
 
// 2.0 builder style
let bob = user::ActiveModel::builder()
    .set_name("Bob")
    .set_email("bob@example.com")
    .insert(db)
    .await?;
println!("inserted: {} {}", bob.id, bob.name);
inserted: Model { id: 1, name: "Alice", email: "alice@example.com" }
inserted: 2 Bob

Find

post::COLUMN.published is one of the new typed column constants. Comparing it to the wrong type is a compile error:

use sea_orm::{EntityTrait, QueryFilter, QueryOrder, QuerySelect};
 
let posts: Vec<post::Model> = post::Entity::find()
    .filter(post::COLUMN.published.eq(true))
    .filter(post::COLUMN.title.contains("Rust"))
    .order_by_desc(post::COLUMN.id)
    .limit(3)
    .all(db)
    .await?;

We checked this: post::COLUMN.published.eq("yes") fails to compile. The older untyped post::Column::Published.eq("yes") compiles, then fails at runtime with operator does not exist: boolean = text.

Update Only What Changed

An ActiveModel records which fields you changed, and the UPDATE only includes those:

use sea_orm::{DbBackend, IntoActiveModel, QueryTrait};
 
let post = post::Entity::find_by_id(1)
    .one(db)
    .await?
    .ok_or(DbErr::RecordNotFound("post 1".into()))?;
 
let mut post = post.into_active_model();
post.title = Set("Updated title".to_owned());
 
let sql = post::Entity::update(post.clone())
    .validate()?
    .build(DbBackend::Postgres)
    .to_string();
println!("{sql}");
 
let updated: post::Model = post.update(db).await?;
UPDATE "posts" SET "title" = 'Updated title' WHERE "posts"."id" = 1

Dynamic Queries

Optional filters are where SeaORM is easiest to use. Condition::add_option skips a None:

use sea_orm::Condition;
 
let cond = Condition::all()
    .add_option(term.map(|t| post::COLUMN.title.contains(t)))
    .add_option(published.map(|p| post::COLUMN.published.eq(p)));
 
post::Entity::find()
    .filter(cond)
    .order_by_asc(post::COLUMN.id)
    .all(db)
    .await

Or use apply_if on the query itself:

post::Entity::find()
    .apply_if(term, |q, t| q.filter(post::COLUMN.title.contains(t)))
    .apply_if(published, |q, p| {
        q.filter(post::COLUMN.published.eq(p))
    })
    .all(db)
    .await

Relations and the Entity Loader

SeaORM has three ways to load related rows. The SQL each one generated is shown after the code:

use sea_orm::{EntityLoaderTrait, LoaderTrait};
 
// 1.x-style join, still available
let rows: Vec<(user::Model, Vec<post::Model>)> = user::Entity::find()
    .find_with_related(post::Entity)
    .all(db)
    .await?;
 
// Model loader: one extra query, `WHERE user_id IN (..)`
let users = user::Entity::find().all(db).await?;
let posts = users.load_many(post::Entity, db).await?;
 
// 2.0 Entity loader: returns ModelEx with `posts` filled in
let alice = user::Entity::load()
    .filter_by_email("alice@example.com")
    .with(post::Entity)
    .one(db)
    .await?
    .expect("alice exists");
println!("entity loader: {} has {} posts", alice.name, alice.posts.len());
SQL: ... FROM "users" LEFT JOIN "posts" ON "users"."id" = "posts"."user_id" ORDER BY "users"."id" ASC
SQL: SELECT ... FROM "users"
SQL: SELECT ... FROM "posts" WHERE ("posts"."user_id") IN ((1), (2)) ORDER BY "posts"."id" ASC
SQL: SELECT ... FROM "users" WHERE "users"."email" = 'alice@example.com' LIMIT 1
SQL: SELECT ... FROM "posts" WHERE ("posts"."user_id") IN ((1)) ORDER BY "posts"."id" ASC
entity loader: Alice has 7 posts

The entity loader loads has-many relations with one extra IN (...) query, and belongs-to relations with a LEFT JOIN in the same query. Either way, you don't get an N+1 loop.

Transactions

transaction_async takes an async closure. It commits on Ok and rolls back on Err:

use sea_orm::TransactionTrait; // for begin()
 
let (user, post) = db
    .transaction_async(async |txn| {
        let user = user::ActiveModel::builder()
            .set_name("Carol")
            .set_email("carol@example.com")
            .insert(txn)
            .await?;
        let post = post::ActiveModel::builder()
            .set_user_id(user.id)
            .set_title("Hello from a txn")
            .set_body("...")
            .set_published(false)
            .insert(txn)
            .await?;
        Ok::<_, DbErr>((user, post))
    })
    .await
    .map_err(|e| DbErr::Custom(e.to_string()))?;

db.begin() and txn.commit() are there if you'd rather manage it by hand.

use sea_orm::PaginatorTrait;
 
let paginator = post::Entity::find()
    .order_by_asc(post::COLUMN.id)
    .paginate(db, 3);
 
let totals = paginator.num_items_and_pages().await?;
println!("{} items, {} pages", totals.number_of_items, totals.number_of_pages);
 
let page_2: Vec<post::Model> = paginator.fetch_page(1).await?;
8 items, 3 pages
page 2 ids: [4, 5, 6]

Pages are numbered from zero.

Migrations

sea-orm-migration migrations are Rust code, written with SeaQuery's schema builder:

// src/m20260925_000001_create_users.rs
use sea_orm_migration::{prelude::*, schema::*};
 
#[derive(DeriveMigrationName)]
pub struct Migration;
 
#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
        m.create_table(
            Table::create()
                .table("users")
                .if_not_exists()
                .col(pk_auto("id"))
                .col(text("name"))
                .col(text_uniq("email"))
                .to_owned(),
        )
        .await
    }
 
    async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
        m.drop_table(Table::drop().table("users").to_owned()).await
    }
}

Register it in a Migrator and run Migrator::up(&db, None).await? on startup, or use sea-orm-cli migrate up. In 2.0, pk_auto creates an IDENTITY column on Postgres rather than SERIAL. The postgres-use-serial-pk feature switches it back.

Where SeaORM Doesn't Check Things

SeaORM checks value types at compile time. It does not check your entities against the database. Here is an entity with a field for a column that doesn't exist:

mod user {
    use sea_orm::entity::prelude::*;
 
    #[sea_orm::model]
    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
    #[sea_orm(table_name = "users")]
    pub struct Model {
        #[sea_orm(primary_key)]
        pub id: i32,
        pub name: String,
        pub email: String,
        pub nickname: String, // not in the database
    }
 
    impl ActiveModelBehavior for ActiveModel {}
}

It compiles with no warnings. The error only shows up when the query runs:

Diesel (through schema.rs) and SQLx (through the live database) would both fail the build in this case. With SeaORM you need tests against a real database to catch it.

SeaORM Tradeoffs

  • Good: the most complete ORM here (relations, loaders, pagination, nested saves, change tracking), async, easy dynamic queries, and good docs.
  • Bad: schema mismatches only show up at runtime, it has the largest dependency tree and slowest build of the four, and it has a small runtime cost from building queries dynamically.
  • Watch out for: mixing 1.x-style and 2.0-style code while you migrate. Both compile, but mixing them makes the code harder to follow.

Rusqlite

rusqlite is a thin, safe wrapper over SQLite's C API. It isn't an ORM and doesn't try to be one. If your database is SQLite and the app is a CLI tool, desktop app, test harness or embedded service, this is the usual choice.

Setup

cargo add rusqlite --features bundled

bundled compiles SQLite into your binary. rusqlite 0.40.2 ships SQLite 3.53.2, so you don't depend on whatever version the system has.

Open and Configure

fn open(path: &str) -> Result<Connection> {
    let conn = Connection::open(path)?;
    conn.execute_batch(
        "PRAGMA foreign_keys = ON;
         PRAGMA journal_mode = WAL;
 
         CREATE TABLE IF NOT EXISTS users (
             id    INTEGER PRIMARY KEY,
             name  TEXT NOT NULL,
             email TEXT NOT NULL UNIQUE
         );
         CREATE TABLE IF NOT EXISTS posts (
             id        INTEGER PRIMARY KEY,
             user_id   INTEGER NOT NULL REFERENCES users(id),
             title     TEXT NOT NULL,
             body      TEXT NOT NULL,
             published INTEGER NOT NULL DEFAULT 0
         );",
    )?;
    Ok(conn)
}

SQLite doesn't enforce foreign keys by default. Run PRAGMA foreign_keys = ON on every connection you open. With it on, inserting a post for a missing user fails with FOREIGN KEY constraint failed.

Insert

params! binds positional parameters. SQLite has supported RETURNING since 3.35:

fn insert_user(conn: &Connection) -> Result<(i64, i64)> {
    conn.execute(
        "INSERT INTO users (name, email) VALUES (?1, ?2)",
        params!["Ferris", "ferris@example.com"],
    )?;
    let ferris_id = conn.last_insert_rowid();
 
    let alice_id: i64 = conn.query_row(
        "INSERT INTO users (name, email) VALUES (?1, ?2)
         RETURNING id",
        params!["Alice", "alice@example.com"],
        |row| row.get(0),
    )?;
    Ok((ferris_id, alice_id))
}

Query into Structs

You map rows yourself:

#[derive(Debug)]
struct Post {
    id: i64,
    title: String,
    published: bool,
}
 
fn published_posts(conn: &Connection) -> Result<Vec<Post>> {
    let mut stmt = conn.prepare(
        "SELECT id, title, published FROM posts
         WHERE published = 1 ORDER BY id",
    )?;
    let posts = stmt
        .query_map([], |row| {
            Ok(Post {
                id: row.get(0)?,
                title: row.get(1)?,
                published: row.get(2)?,
            })
        })?
        .collect::<Result<Vec<_>>>()?;
    Ok(posts)
}

Named parameters make longer queries easier to read:

fn count_posts_by(conn: &Connection, email: &str) -> Result<i64> {
    conn.query_row(
        "SELECT COUNT(*) FROM posts p
         JOIN users u ON u.id = p.user_id
         WHERE u.email = :email",
        named_params! { ":email": email },
        |row| row.get(0),
    )
}

Transactions (and Why They Matter So Much in SQLite)

fn create_user_with_post(conn: &mut Connection) -> Result<i64> {
    let tx = conn.transaction()?;
    tx.execute(
        "INSERT INTO users (name, email) VALUES (?1, ?2)",
        params!["Bob", "bob@example.com"],
    )?;
    let user_id = tx.last_insert_rowid();
    tx.execute(
        "INSERT INTO posts (user_id, title, body, published)
         VALUES (?1, ?2, ?3, 1)",
        params![user_id, "Hello", "First post"],
    )?;
    tx.commit()?; // dropping `tx` without commit rolls back
    Ok(user_id)
}

Outside a transaction, SQLite commits and syncs to disk after every statement. For bulk writes, use one transaction and prepare_cached:

fn bulk_insert(
    conn: &mut Connection,
    user_id: i64,
    n: usize,
) -> Result<()> {
    let tx = conn.transaction()?;
    for i in 0..n {
        let mut stmt = tx.prepare_cached(
            "INSERT INTO posts (user_id, title, body)
             VALUES (?1, ?2, ?3)",
        )?;
        stmt.execute(params![user_id, format!("Post {i}"), "..."])?;
    }
    tx.commit()
}

We timed 10,000 inserts into a file database (release build, WAL mode):

SetupTime
One transaction14 ms
One transaction, synchronous = NORMAL13 ms
Autocommit, synchronous = NORMAL1.37 s
Autocommit, default synchronous = FULL22.9 s

The autocommit times depend almost entirely on how fast your disk syncs, so yours will differ. The ratio won't change much: one transaction was over 1,000x faster than autocommit here.

Migrations

rusqlite has no migration system of its own. rusqlite_migration is a small crate that does the job:

fn migrate(conn: &mut Connection) {
    let migrations = Migrations::new(vec![
        M::up(
            "CREATE TABLE users (
                id    INTEGER PRIMARY KEY,
                name  TEXT NOT NULL,
                email TEXT NOT NULL UNIQUE
            );",
        ),
        M::up(
            "CREATE TABLE posts (
                id        INTEGER PRIMARY KEY,
                user_id   INTEGER NOT NULL REFERENCES users(id),
                title     TEXT NOT NULL,
                body      TEXT NOT NULL,
                published INTEGER NOT NULL DEFAULT 0
            );",
        )
        .down("DROP TABLE posts;"),
    ]);
    migrations.to_latest(conn).expect("migrations failed");
}

It keeps the current version in SQLite's PRAGMA user_version instead of a separate table.

Custom Types

Implement ToSql and FromSql to store your own types. Here's an enum stored as TEXT:

#[derive(Debug, Clone, Copy, PartialEq)]
enum Status {
    Draft,
    Published,
}
 
impl ToSql for Status {
    fn to_sql(&self) -> Result<ToSqlOutput<'_>> {
        let s = match self {
            Status::Draft => "draft",
            Status::Published => "published",
        };
        Ok(s.into())
    }
}
 
impl FromSql for Status {
    fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
        match value.as_str()? {
            "draft" => Ok(Status::Draft),
            "published" => Ok(Status::Published),
            other => Err(FromSqlError::other(
                std::io::Error::other(format!("bad status {other}")),
            )),
        }
    }
}

Using rusqlite from Async Code

rusqlite is synchronous. Calling it directly in a Tokio task blocks that worker thread. The simplest fix is spawn_blocking:

async fn count_users_blocking(path: String) -> Result<i64> {
    tokio::task::spawn_blocking(move || {
        let conn = Connection::open(path)?;
        conn.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0))
    })
    .await
    .expect("blocking task panicked")
}

For a long-lived connection, tokio-rusqlite runs each connection on its own background thread and sends your closures to it:

async fn tokio_rusqlite_demo(path: &str) -> tokio_rusqlite::Result<()> {
    let conn = tokio_rusqlite::Connection::open(path).await?;
 
    let titles = conn
        .call(|conn| {
            let mut stmt = conn.prepare(
                "SELECT title FROM posts WHERE published = 1",
            )?;
            stmt.query_map([], |row| row.get::<_, String>(0))?
                .collect::<rusqlite::Result<Vec<_>>>()
        })
        .await?;
 
    println!("published titles: {titles:?}");
    Ok(())
}

If you'd rather have native async SQLite, SQLx and SeaORM both support SQLite, and Diesel supports it too.

Rusqlite Tradeoffs

  • Good: small (about 10 crates in the dependency tree), fast to build, gives you all of SQLite, and needs no system dependencies with bundled.
  • Bad: SQLite only, synchronous, no compile-time query checks, and you map rows by hand.
  • Watch out for: foreign keys being off by default, and slow bulk writes if you forget to use a transaction.

Feature Comparison

DieselSQLxSeaORMrusqlite
Query styleRust DSLSQL stringsQuery builder + ActiveModelSQL strings
Wrong column name caughtCompile timeCompile time (macros)RuntimeRuntime
Wrong value type caughtCompile timeCompile time (macros)Compile time (COLUMN API)Runtime
AsyncVia diesel-asyncYesYesVia spawn_blocking / tokio-rusqlite
Dynamic filtersinto_boxed()QueryBuilderCondition, apply_ifBuild the string yourself
Relations / eager loadingbelonging_to + grouped_byWrite the joinhas_many / belongs_to, entity loaderWrite the join
Pagination helperNoNoYesNo
MigrationsSQL files (diesel_cli)SQL files (sqlx-cli)Rust code (sea-orm-migration)rusqlite_migration
Needs a DB to compileNoYes, or .sqlx cacheNoNo
Needs a system librarylibpq (sync Postgres)NoNoNo (with bundled)

Compile Times

We measured a clean debug build of a project that depends on only the library (plus Tokio for the async ones), with an empty main. That measures the dependency cost, not your own code. Each project was built twice, one at a time, on a 12-core Linux machine with Rust 1.98.1:

ProjectCrates in treeClean debug build
rusqlite (bundled)~9~8 s
SQLx (postgres, runtime-tokio, tls-rustls)~174~19 s
Diesel (postgres)~26~21 s
diesel-async (postgres, bb8)~91~21 s
SeaORM (sqlx-postgres, runtime-tokio-rustls, macros)~213~29 s

Two things this table doesn't show:

  • Diesel's cost grows with your schema. table! and the derives produce a lot of generic code, so type-checking takes longer as you add tables and queries.
  • SQLx's cost grows with your queries. Every query! call checks against the database (or the .sqlx cache) during the build.

Incremental builds matter more than clean ones day to day, and all four are fine for that.

Runtime Performance

For most web apps, the choice of library won't be what limits performance. Network round trips, missing indexes and N+1 queries will be.

The most complete public benchmark is Diesel's own diesel_bench. It compares Diesel, diesel-async, SQLx, SeaORM, rusqlite, tokio-postgres and others on simple queries, joins, inserts and loading associations. Results are published in diesel-rs/metrics. Two caveats:

  • Diesel's maintainers wrote it.
  • At the time of writing, it pins SQLx 0.8.6 and SeaORM 1.1, so it doesn't measure SQLx 0.9 or SeaORM 2.0.

What you can say safely:

  • Diesel, SQLx and rusqlite add very little on top of the driver.
  • SeaORM has a measurable cost from building queries at runtime and converting to and from ActiveModel. That matters mostly in tight loops or bulk work.
  • Query pipelining can matter more than which library you pick. diesel-async supports it on Postgres, and it's where the crates.io speedup came from.

If performance decides it for you, run your own benchmark with your own queries.

Popularity and Maintenance

Figures from crates.io and GitHub as of September 2026:

CrateLatest releaseAll-time downloadsLast 90 daysGitHub stars
sqlx0.9.0 (May 2026)151.9M39.2M17.5K
rusqlite0.40.2 (Aug 2026)111.7M36.0M4.4K
diesel2.3.13 (Sep 2026)37.4M7.6M14.2K
sea-orm2.0.3 (Sep 2026)25.5M4.3M9.9K
diesel-async0.9.2 (Jun 2026)13.5M4.4M0.8K

Download counts overstate SQLx and rusqlite somewhat, because other crates depend on them. SeaORM depends on SQLx, and many crates pull in rusqlite. Even so, all five are actively maintained, with commits within the last few weeks.

Other Libraries Worth Knowing

  • Toasty (0.10): an async ORM from the Tokio team that targets SQL databases and DynamoDB. It was first released in April 2026, and its maintainers say it's early and will have breaking changes before 1.0. Keep an eye on it, but don't build on it yet.
  • Cornucopia (1.0): you write queries in .sql files and it generates typed Rust functions, like sqlc in Go. PostgreSQL only. Its fork Clorinde merged back into it for the 1.0 release, and Clorinde is now archived.
  • tokio-postgres: the async Postgres driver that diesel-async is built on. Use it directly if you want no abstraction at all.
  • Turso: a SQLite-compatible database rewritten in Rust (formerly "Limbo"). Pre-1.0, but it's the direction the libSQL team is heading.
  • RBATIS: an ORM modelled on Java's MyBatis, with support for many databases. It'll feel familiar if you come from MyBatis and unusual if you don't.
  • ormx: a thin layer of derives on top of SQLx. Its last release was in September 2024, and it depends on SQLx 0.8, so it doesn't work with SQLx 0.9. Consider it unmaintained.

How to Choose

  1. Is the database SQLite, embedded in a CLI or desktop app? Use rusqlite. If the rest of the app is async and uses Postgres too, use SQLx for both.
  2. Do you want to write SQL yourself? Use SQLx. You get compile-time checks with no DSL to learn.
  3. Do you want compile-time checks without SQL strings, and a stable schema? Use Diesel, with diesel-async in async apps.
  4. Is your data model heavy on relations, with lots of user-driven filtering and pagination? Use SeaORM.
  5. Still not sure? Start with SQLx. It has the smallest learning curve for anyone who knows SQL, it's the most widely used, and you can add SeaQuery or SeaORM later without switching drivers, since SeaORM runs on SQLx.

If you're building a web API, our Axum tutorial wires SQLx 0.9 into a complete CRUD service.

FAQ

What is the best ORM for Rust in 2026?

It depends on what you want checked, and when. SeaORM is the most complete ORM. Diesel has the strongest compile-time guarantees. SQLx isn't an ORM, but it's the most used Rust database library and a good default. For SQLite, rusqlite is the standard choice.

Is SQLx an ORM?

No. SQLx runs SQL you write and maps the rows to structs. It has no models, relations or change tracking. The query! macros check your SQL against a real database at compile time, which is what makes it safe to use without an ORM.

Is Diesel async?

Diesel's core is synchronous. The diesel-async crate, maintained by the Diesel team, adds async connections for PostgreSQL and MySQL with bb8, deadpool or mobc pools. crates.io runs on it.

Diesel vs SQLx: which should I use?

Choose Diesel if you want the compiler to check queries without needing a database at build time, and you're OK learning a DSL. Choose SQLx if you'd rather write SQL and don't mind a database (or a committed .sqlx cache) at build time. Their runtime performance is close.

SQLx vs SeaORM: which should I use?

SeaORM runs on SQLx, so the question is whether you want the ORM layer. Choose SeaORM for relations, eager loading, pagination and change-tracked updates. Choose SQLx if you want to see and control every query, and have mismatched columns caught at compile time instead of at runtime.

Does SeaORM use SQLx?

Yes. SeaORM 2.0 builds queries with SeaQuery 1.0 and runs them through SQLx 0.9 for PostgreSQL, MySQL and SQLite.

What is the best Rust library for SQLite?

rusqlite for synchronous code, and for anything where SQLite is the only database. SQLx if you want async and compile-time checked SQL, or need to support SQLite and Postgres with the same code. Diesel and SeaORM also support SQLite.

Is rusqlite async?

No. Use tokio::task::spawn_blocking or the tokio-rusqlite crate to call it from async code without blocking the runtime.

Does SQLx support SQL Server?

Not in 0.9. MSSQL support was removed in SQLx 0.7, and the maintainers plan to bring it back as part of a separate "SQLx Pro" product. SeaORM's SQL Server support is only in its commercial SeaORM X edition.

How do I build a SQLx project without a database?

Run cargo sqlx prepare against a database once, commit the generated .sqlx/ folder, and build with SQLX_OFFLINE=true. Re-run prepare whenever a query changes, and add cargo sqlx prepare --check to CI.

Can I use Diesel and SQLx in the same project?

Yes. Nothing stops you. They use separate connections and pools, so you can't share a transaction between them. Using both usually makes sense only during a gradual migration from one to the other.

Which one works best with Axum?

All of the async options work with Axum: SQLx, SeaORM and diesel-async. Put the pool in your app state and pull it out in handlers. SQLx is the most common pairing, and it's what our Axum tutorial uses.

Final Thoughts

The Rust database libraries are all mature enough now that you won't regret any of these four. The real question is where you want errors caught (in the compiler, from the database, or in tests) and how much of the SQL you want to write yourself.

SQLx is the safe default. Diesel gives the most compile-time checking. SeaORM does the most for you. rusqlite is the standard for SQLite. Choose the one that fits how your team already thinks about databases.

Subscribe to our newsletter

Get the latest updates on courses, features, tools, and resources about Rust.

Ferris the Rust crab

Learn Rust by Practice

Master Rust through hands-on coding exercises and real-world examples.

Get Started