SeaORM & Diesel
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against SeaORM 2.0.4 (sea-query 1.0, on sqlx 0.9), Diesel 2.3.13 and diesel-async 0.9.2 with axum 0.8.9 on Rust 1.99 (edition 2024), checked on 2026-10-06. Bookshelf uses sqlx (see Databases with sqlx); this page shows the same tables through the two main Rust ORMs, for teams that prefer generated queries and typed query builders. The examples run against the schema created on the sqlx pages.
SeaORM
SeaORM is an async, dynamic ORM built on sqlx and the SeaQuery query builder. Each table is a module with a Model
(a row), an Entity (the table), a Column enum, a Relation enum and an ActiveModel (a row being edited, where
each field is Set, Unchanged or NotSet).
[dependencies]
sea-orm = { version = "2.0.4", features = ["sqlx-postgres", "runtime-tokio-rustls", "macros"] }
sea-orm-migration = { version = "2.0.4", features = ["sqlx-postgres", "runtime-tokio-rustls"] } # migration crate only
Source: SeaORM: Database & Async Runtime
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
routing::get,
};
use sea_orm::{
ActiveModelTrait, ColumnTrait, ConnectOptions, Database, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder,
Set,
};
use serde::{Deserialize, Serialize};
mod author {
use sea_orm::entity::prelude::*;
use serde::Serialize;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)]
#[sea_orm(table_name = "authors")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub name: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::book::Entity")]
Book,
}
impl Related<super::book::Entity> for Entity {
fn to() -> RelationDef {
Relation::Book.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
}
mod book {
use sea_orm::entity::prelude::*;
use serde::Serialize;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)]
#[sea_orm(table_name = "books")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub isbn: String,
pub title: String,
pub author_id: i64,
pub year: i16,
pub copies: i32,
pub version: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(belongs_to = "super::author::Entity", from = "Column::AuthorId", to = "super::author::Column::Id")]
Author,
}
impl Related<super::author::Entity> for Entity {
fn to() -> RelationDef {
Relation::Author.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
}
#[derive(Serialize)]
struct BookWithAuthor {
#[serde(flatten)]
book: book::Model,
author: Option<String>,
}
fn internal(err: sea_orm::DbErr) -> StatusCode {
eprintln!("database error: {err}");
StatusCode::INTERNAL_SERVER_ERROR
}
async fn list(State(db): State<DatabaseConnection>) -> Result<Json<Vec<BookWithAuthor>>, StatusCode> {
let rows = book::Entity::find()
.filter(book::Column::Year.gte(1965))
.order_by_asc(book::Column::Title)
.find_also_related(author::Entity) // one query with a LEFT JOIN
.all(&db)
.await
.map_err(internal)?;
Ok(Json(rows.into_iter().map(|(book, author)| BookWithAuthor { book, author: author.map(|a| a.name) }).collect()))
}
#[derive(Deserialize)]
struct NewBook {
isbn: String,
title: String,
author_id: i64,
year: i16,
}
async fn create(State(db): State<DatabaseConnection>, Json(input): Json<NewBook>) -> Result<(StatusCode, Json<book::Model>), StatusCode> {
let model = book::ActiveModel {
isbn: Set(input.isbn),
title: Set(input.title),
author_id: Set(input.author_id),
year: Set(input.year),
..Default::default() // copies and version keep their database defaults
}
.insert(&db)
.await
.map_err(|err| match err.sql_err() {
Some(sea_orm::SqlErr::UniqueConstraintViolation(_)) => StatusCode::CONFLICT,
_ => internal(err),
})?;
Ok((StatusCode::CREATED, Json(model)))
}
async fn rename(State(db): State<DatabaseConnection>, Path(isbn): Path<String>, title: String) -> Result<Json<book::Model>, StatusCode> {
let found = book::Entity::find_by_id(isbn).one(&db).await.map_err(internal)?.ok_or(StatusCode::NOT_FOUND)?;
let version = found.version;
let mut active: book::ActiveModel = found.into();
active.title = Set(title);
active.version = Set(version + 1);
Ok(Json(active.update(&db).await.map_err(internal)?))
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut options = ConnectOptions::new(std::env::var("DATABASE_URL")?);
options.max_connections(10).sqlx_logging(false);
let db: DatabaseConnection = Database::connect(options).await?;
let app = Router::new()
.route("/api/v1/books", get(list).post(create))
.route("/api/v1/books/{isbn}/title", axum::routing::put(rename))
.with_state(db);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
Source: SeaORM documentation and sea-orm 2.0.4
$ curl -s localhost:3000/api/v1/books | jq '.[0]'
{ "isbn": "9780441013593", "title": "Dune", "author_id": 1, "year": 1965, "copies": 2, "version": 1, "author": "Frank Herbert" }
$ curl -si -H 'content-type: application/json' -d '{"isbn":"9780061054884","title":"The Dispossessed","author_id":2,"year":1974}' localhost:3000/api/v1/books | head -1
HTTP/1.1 201 Created
$ curl -si -H 'content-type: application/json' -d '{"isbn":"9780061054884","title":"The Dispossessed","author_id":2,"year":1974}' localhost:3000/api/v1/books | head -1
HTTP/1.1 409 Conflict
Source: SeaORM: Insert
-
DatabaseConnectionis a cloneable pool handle, used as axum state likePgPool. -
Entities are usually generated from an existing database with
sea-orm-cli generate entity, or written by hand as above.#[sea_orm(primary_key, auto_increment = false)]marks a natural key such as the ISBN. -
find_also_relatedproduces oneLEFT JOINquery;find_with_relatedloads one-to-many children; SeaORM 2.0 also offers an entity loader (Entity::load().with(...)) that batches related queries to avoid N+1. -
err.sql_err()classifies database errors (UniqueConstraintViolation,ForeignKeyConstraintViolation) without matching on SQLSTATE strings. -
Queries SeaORM cannot express are written with SeaQuery (
sea_orm::sea_query::Query::select()...) or as raw SQL withStatement::from_sql_and_values.
Migrations with sea-orm-migration
SeaORM migrations are Rust code, usually in a separate migration crate generated by sea-orm-cli migrate init.
DeriveIden turns an enum into table and column identifiers, so names are not repeated as strings:
use sea_orm::Database;
use sea_orm_migration::prelude::*;
#[derive(DeriveIden)]
enum Shelves {
Table,
Id,
Name,
Floor,
}
#[derive(DeriveMigrationName)]
struct Migration; // name derived from the module/file name: "seamigrate"
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Shelves::Table)
.if_not_exists()
.col(ColumnDef::new(Shelves::Id).big_integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(Shelves::Name).string().not_null().unique_key())
.col(ColumnDef::new(Shelves::Floor).small_integer().not_null().default(0))
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager.drop_table(Table::drop().table(Shelves::Table).to_owned()).await
}
}
struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![Box::new(Migration)]
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let db = Database::connect(std::env::var("DATABASE_URL")?).await?;
Migrator::up(&db, None).await?; // applies pending migrations, recorded in seaql_migrations
println!("applied: {:?}", Migrator::get_applied_migrations(&db).await?.iter().map(|m| m.name().to_owned()).collect::<Vec<_>>());
Ok(())
}
Source: SeaORM: Writing Migration
$ cargo run --bin seamigrate
applied: ["seamigrate"]
Source: SeaORM: Running Migration
On Postgres, SeaORM 2.0 creates auto-increment keys as GENERATED BY DEFAULT AS IDENTITY columns; the
postgres-use-serial-pk feature restores the legacy BIGSERIAL. SeaORM 2.0 can also synchronise the schema from
entity definitions (get_schema_builder().register(...).sync(db)), handy for prototypes; keep explicit migrations
for production.
What changed since SeaORM 1.0
Yoon (Beginning Axum, chapter 3) uses SeaORM 1.0. SeaORM 2.0 (July 2026) moved to sqlx 0.9 and SeaQuery 1.0 and adds:
-
the
#[sea_orm::model]entity format, in which relations are fields (HasOne,HasMany, and since 2.0.0BelongsTo) instead of a hand-writtenRelationenum, and which generates strongly typed columns (book::COLUMN.title.contains(...)), so comparing a text column with an integer is a compile error; -
nested
ActiveModelbuilders that save a graph of related rows in one call; -
the entity loader for related data,
insert_manyreturningOptionids and refusing empty input unlesson_empty_do_nothing()is set, and role-based access control throughRestrictedConnection.
The classic DeriveEntityModel + Relation enum style used above remains supported (without COLUMN), so 1.0 code mostly compiles after
updating the dependency and fixing insert_many call sites.
Diesel with diesel-async
Diesel is the oldest Rust ORM: a fully static query builder whose queries are checked by the type system (not against
a live database), with migrations managed by diesel_cli. Diesel itself is synchronous; diesel-async provides async
connections (AsyncPgConnection, built on tokio-postgres, so no libpq is needed) and pools for deadpool, bb8 and
mobc:
[dependencies]
diesel = { version = "2.3.13", default-features = false, features = ["postgres_backend"] }
diesel-async = { version = "0.9.2", features = ["postgres", "deadpool"] }
use axum::{
Json, Router,
extract::{Query, State},
http::StatusCode,
routing::get,
};
use diesel::prelude::*;
use diesel_async::{
AsyncPgConnection, RunQueryDsl,
pooled_connection::{AsyncDieselConnectionManager, deadpool::Pool},
};
use serde::{Deserialize, Serialize};
// Normally generated by `diesel print-schema` into src/schema.rs
diesel::table! {
books (isbn) {
isbn -> Text,
title -> Text,
author_id -> BigInt,
year -> SmallInt,
copies -> Integer,
version -> BigInt,
}
}
#[derive(Queryable, Selectable, Serialize)]
#[diesel(table_name = books)]
#[diesel(check_for_backend(diesel::pg::Pg))]
struct Book {
isbn: String,
title: String,
year: i16,
}
#[derive(Deserialize)]
struct Since {
year: i16,
}
type DbPool = Pool<AsyncPgConnection>;
async fn since(State(pool): State<DbPool>, Query(q): Query<Since>) -> Result<Json<Vec<Book>>, StatusCode> {
let mut conn = pool.get().await.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?;
let rows = books::table
.filter(books::year.ge(q.year))
.order(books::title.asc())
.select(Book::as_select())
.load(&mut conn)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(rows))
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let manager = AsyncDieselConnectionManager::<AsyncPgConnection>::new(std::env::var("DATABASE_URL")?);
let pool = Pool::builder(manager).max_size(10).build()?;
let app = Router::new().route("/api/v1/books", get(since)).with_state(pool);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
$ curl -s 'localhost:3000/api/v1/books?year=1969'
[{"isbn":"9780593099322","title":"Dune Messiah","year":1969},{"isbn":"9780441478125","title":"The Left Hand of Darkness","year":1969}]
The table! block is normally generated by diesel print-schema after diesel migration run. The official axum
examples cover both the synchronous style (diesel-postgres, using deadpool-diesel to run blocking queries on a
thread pool) and the async one shown here.
Comparison
| sqlx 0.9 | SeaORM 2.0 | Diesel 2.3 + diesel-async | |
|---|---|---|---|
Style |
SQL strings, mapped to structs |
dynamic ORM + query builder |
static, type-checked query builder |
Compile-time checks |
queries checked against a live DB or |
types and columns, not SQL validity |
queries checked by the Rust type system |
Async |
native |
native (on sqlx) |
through diesel-async |
Migrations |
SQL files, |
Rust code ( |
SQL files, |
Relations |
hand-written joins |
|
joins and |
Learning curve |
SQL knowledge |
moderate |
steepest (complex generic types) |
Best for |
teams fluent in SQL, complex queries |
CRUD-heavy apps, admin panels, Loco |
maximum static safety |
All three are production-ready. Choose sqlx if you want to see the SQL and have it checked, SeaORM for productive CRUD with relations (Loco builds on it), Diesel when you want queries to be ordinary Rust expressions verified by the compiler.