Every time someone new to Rust asks "which database crate should I use?", a thread opens on Reddit that generates 200 replies, three flame wars, and zero actionable conclusions. This article is the one I wish had existed when I had to make this call myself.
The short version: sqlx, Diesel, and SeaORM are all solid choices — but they solve different problems, and picking the wrong one will cost you. Let me break down exactly how they differ, where each one shines, and where each one will kick you in the teeth.
All three support PostgreSQL, MySQL/MariaDB, and SQLite. The comparison focuses on Postgres because that’s what you should be using for anything serious.
The Core Philosophy Difference
Before comparing syntax, understand what each library actually is:
- sqlx — you write SQL, the library checks it at compile time. That’s it. No ORM, no DSL.
- Diesel — a query builder with a type-safe DSL. You rarely write raw SQL; instead you compose queries from Rust types that map 1:1 to your schema.
- SeaORM — a full async ORM with entity models, similar in feel to ActiveRecord or Django ORM. Built on top of sqlx internals.
This isn’t just an API difference. It’s a fundamental difference in where the abstraction layer sits. Getting this wrong means either fighting the library constantly or rewriting half your data layer six months in.
sqlx
GitHub: launchbadge/sqlx
sqlx is the closest thing Rust has to "just use SQL, but safely." You write a query as a string macro, and at compile time the macro connects to your database, parses the query against the live schema, and verifies that the Rust types you’re mapping to are compatible with what the DB would actually return.
# Cargo.toml
[dependencies]
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "macros"] }
tokio = { version = "1", features = ["full"] }
Basic usage
use sqlx::PgPool;
#[derive(sqlx::FromRow)]
struct User {
id: i32,
email: String,
created_at: chrono::DateTime<chrono::Utc>,
}
async fn get_user(pool: &PgPool, id: i32) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as!(
User,
"SELECT id, email, created_at FROM users WHERE id = $1",
id
)
.fetch_optional(pool)
.await
}
The query_as! macro verifies the query against your database at compile time. If you rename a column, this breaks at compile, not at runtime. If you return a nullable column into a non-Option field, it breaks at compile.
Offline mode
The compile-time check requires a live database connection, which is annoying in CI. sqlx solves this with cargo sqlx prepare — it serialises the query metadata into a .sqlx/ directory that gets committed to version control. From then on, SQLX_OFFLINE=true cargo build works without a DB.
# Run once to generate metadata
DATABASE_URL=postgres://user:pass@localhost/mydb cargo sqlx prepare
# CI build (no database needed)
SQLX_OFFLINE=true cargo build
Commit the .sqlx/ directory. Don’t gitignore it.
Migrations
sqlx ships a migration runner that’s dead simple:
# Install the CLI
cargo install sqlx-cli --no-default-features --features rustls,postgres
# Create a migration
sqlx migrate add create_users_table
# Run migrations
sqlx migrate run --database-url $DATABASE_URL
Migration files are plain SQL. No magic, no DSL:
-- migrations/20260501120000_create_users_table.sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
What sqlx is good at
Complex analytical queries, CTEs, window functions, RETURNING clauses, custom Postgres types — anything where you’re actually using your database’s features rather than treating it as a dumb key-value store. The library gets out of your way completely.
Gotchas
The compile-time check is a double-edged sword. Yes, you catch errors at compile time. But you also need to manage the .sqlx/ directory carefully. If you run cargo sqlx prepare against a schema that’s slightly behind main, you’ll have stale metadata in CI and subtle failures. Make prepare part of your PR workflow, not an afterthought.
No query builder. If you need to build a query dynamically — say, optional filter conditions — you’re concatenating SQL strings manually, which can get messy. There’s a QueryBuilder struct for this, but it’s not as ergonomic as Diesel’s approach:
// Dynamic filters in sqlx — workable, but verbose
let mut builder = sqlx::QueryBuilder::new("SELECT * FROM users WHERE 1=1");
if let Some(email) = filter_email {
builder.push(" AND email = ").push_bind(email);
}
let query = builder.build_query_as::<User>();
Diesel
GitHub: diesel-rs/diesel
Diesel is the oldest of the three and has the strongest compile-time safety story. It’s not an ORM in the ActiveRecord sense — it’s a type-safe query builder. You don’t write SQL; you write Rust expressions that Diesel compiles down to SQL.
[dependencies]
diesel = { version = "2.2", features = ["postgres", "chrono"] }
diesel-async = { version = "0.5", features = ["postgres", "tokio"] }
Diesel is synchronous by default. diesel-async is a separate crate maintained by the same team that adds async support — it works well but it’s one more dependency to track.
Schema first
Diesel uses a schema.rs file that describes your database schema as Rust types. You generate it from your migrations using the Diesel CLI:
cargo install diesel_cli --no-default-features --features postgres
diesel setup
diesel migration generate create_users
-- migrations/2026-05-01-120000_create_users/up.sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
diesel migration run
# This generates src/schema.rs automatically
The generated schema.rs looks like:
// src/schema.rs (auto-generated, don't hand-edit)
diesel::table! {
users (id) {
id -> Int4,
email -> Varchar,
created_at -> Timestamp,
}
}
Querying
use diesel::prelude::*;
use diesel_async::{RunQueryDsl, AsyncPgConnection};
#[derive(Queryable, Selectable)]
#[diesel(table_name = crate::schema::users)]
struct User {
id: i32,
email: String,
created_at: chrono::NaiveDateTime,
}
async fn get_user(conn: &mut AsyncPgConnection, user_id: i32) -> QueryResult<Option<User>> {
use crate::schema::users::dsl::*;
users
.filter(id.eq(user_id))
.select(User::as_select())
.first(conn)
.await
.optional()
}
Where Diesel genuinely wins
Dynamic filters. If you’re building a search endpoint with a dozen optional parameters, Diesel’s approach is considerably cleaner than sqlx’s string building:
async fn search_users(
conn: &mut AsyncPgConnection,
email_filter: Option<&str>,
active_only: bool,
) -> QueryResult<Vec<User>> {
use crate::schema::users::dsl::*;
let mut query = users.into_boxed();
if let Some(e) = email_filter {
query = query.filter(email.eq(e));
}
if active_only {
query = query.filter(active.eq(true));
}
query.load(conn).await
}
The into_boxed() call turns the query into a heap-allocated dynamic type so you can conditionally chain filters. This pattern is Diesel’s killer feature — it composes cleanly, and every filter is still type-checked.
Gotchas
Joins are painful. This is Diesel’s most common complaint, and it’s earned. A join in Diesel requires understanding how Diesel’s type system tracks which tables are in scope, and multi-level joins produce type signatures that look like a Lovecraftian horror:
// A simple join — already verbose
users::table
.inner_join(posts::table)
.select((users::id, users::email, posts::title))
.load::<(i32, String, String)>(conn)
.await
For three-table joins, be prepared to write several lines of type annotations. If your application has a complex relational model with lots of joins, sqlx will feel like a relief by comparison.
The DSL has a learning curve. The Diesel query DSL is well-designed but it’s a new language to learn on top of Rust. Budget time for it. The documentation is good; you’ll still spend time in it.
Async is an add-on. diesel-async is excellent and production-ready, but it’s a separate crate with its own versioning. Keep an eye on version compatibility when upgrading.
SeaORM
GitHub: SeaQL/sea-orm
SeaORM is the youngest of the three and the most "opinionated ORM" of the bunch. It’s fully async (no sync mode, no add-on crate), and it’s built on top of sqlx. The model is entity-based — you define structs that represent database tables, and the framework generates query methods for them.
[dependencies]
sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls", "macros"] }
Entities
SeaORM has a code generator (sea-orm-cli) that creates entity files from an existing database:
cargo install sea-orm-cli
sea-orm-cli generate entity -u postgres://user:pass@localhost/mydb -o src/entities
Or write them manually:
// src/entities/user.rs
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub email: String,
pub created_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
CRUD operations
use sea_orm::*;
use crate::entities::user::{self, Entity as User};
// Find by primary key
let user = User::find_by_id(1).one(&db).await?;
// Insert
let new_user = user::ActiveModel {
email: ActiveValue::Set("[email protected]".to_owned()),
..Default::default()
};
User::insert(new_user).exec(&db).await?;
// Update
let mut user: user::ActiveModel = user.unwrap().into();
user.email = ActiveValue::Set("[email protected]".to_owned());
user.update(&db).await?;
// Delete
user.delete(&db).await?;
If you’ve used Django ORM or ActiveRecord, this feels immediately familiar. The ergonomics for CRUD-heavy apps are genuinely nice.
Migrations with SeaMigration
SeaORM’s migration system (sea-orm-migration) is Rust-based rather than SQL-based, which means migrations are type-checked:
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Users::Table)
.col(ColumnDef::new(Users::Id).integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(Users::Email).string().not_null().unique_key())
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(Users::Table).to_owned()).await
}
}
Some people love this. I find it verbose compared to plain SQL migrations, and it can’t express everything Postgres supports natively. But it’s a valid approach.
Gotchas
API stability. SeaORM is younger than the others and has had breaking changes between minor versions. If you start a project on SeaORM, pin your dependency tightly and block upgrades as a separate task, not an afterthought.
Performance overhead. SeaORM has more abstraction layers than sqlx. For the vast majority of applications this is completely irrelevant. If you’re writing a high-throughput service where every microsecond on the hot path matters, you’ll notice it.
Complex queries require dropping to raw SQL. When you need to write a CTE or a complex window function, SeaORM can’t express it through its builder. You drop down to raw SQL execution, which is fine, but at that point you’re mixing two approaches in the same codebase. sqlx is more consistent here.
The ActiveModel pattern is verbose. The ActiveValue::Set(...) / ActiveValue::NotSet pattern is necessary for partial updates but gets tedious to write. Libraries like seaography help with this in some use cases, but it’s friction you don’t have with Diesel or sqlx.
Head-to-Head Summary
| Criterion | sqlx | Diesel | SeaORM |
|---|---|---|---|
| Async | Native | Via diesel-async |
Native |
| Query style | Raw SQL | Type-safe DSL | Entity methods |
| Compile-time safety | SQL verified | Full DSL | Partial |
| Migrations | SQL files | SQL files | Rust code |
| Complex queries | Excellent | Awkward for joins | Drop to raw SQL |
| Dynamic filters | Verbose | Excellent | Good |
| CRUD ergonomics | Manual | Manual | Excellent |
| Learning curve | Low | High | Medium |
| API stability | Mature | Very mature | Still stabilising |
Production-Ready Decisions
Use sqlx when: you’re writing a service with complex domain logic and non-trivial queries (analytics, reporting, event sourcing). When you’re comfortable writing SQL and don’t want a leaky abstraction between you and the database. When you care about correctness more than convenience. When your schema doesn’t change often and your queries tend to be bespoke.
Use Diesel when: your application has many optional query filters (search APIs, faceted filtering, admin dashboards). When you want the strongest possible compile-time guarantees and are willing to pay the learning curve. When your team is already writing a lot of Rust and can internalise the DSL. Avoid it if you have many complex joins or need async on day one without managing two crate versions.
Use SeaORM when: you’re building a standard CRUD application and want ergonomics similar to Django or ActiveRecord. When your team is coming from a Python/Ruby/Node background and wants a familiar pattern. When rapid prototyping matters more than query-level control. Accept that you’ll write some raw SQL for anything non-trivial.
What I Actually Use
For new projects, my default is sqlx. It forces you to know your schema, which is generally a good thing. The compile-time verification catches real bugs. The library is thin enough that upgrading Postgres or switching query patterns is never a rewrite.
The only time I’d reach for Diesel is if the application is search/filter-heavy and the team is experienced enough to get past the learning curve without it becoming a productivity drain.
SeaORM I’d seriously consider for a side project or internal tool where I want to move fast, the query complexity is low, and I don’t care much about long-term API stability. For production services with SLAs, I’d wait until the library’s major version cadence settles down a bit more.
One thing all three share: write an integration test that runs against a real database. Don’t mock the database layer in tests. You’re in Rust — you can spin up a Postgres container in CI with testcontainers-rs and test against real queries. The entire point of these crates is compile-time safety; don’t throw away the runtime half.
# For real integration tests
[dev-dependencies]
testcontainers = "0.23"
testcontainers-modules = { version = "0.11", features = ["postgres"] }
Pick the crate that matches your query complexity and team experience, commit to it, and stop switching. The ecosystem around all three is stable enough that any of them will serve you well for years.