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
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(())
}
$ 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

  • DatabaseConnection is a cloneable pool handle, used as axum state like PgPool.

  • 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_related produces one LEFT JOIN query; find_with_related loads 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 with Statement::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(())
}
$ cargo run --bin seamigrate
applied: ["seamigrate"]

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.0 BelongsTo) instead of a hand-written Relation enum, and which generates strongly typed columns (book::COLUMN.title.contains(...)), so comparing a text column with an integer is a compile error;

  • nested ActiveModel builders that save a graph of related rows in one call;

  • the entity loader for related data, insert_many returning Option ids and refusing empty input unless on_empty_do_nothing() is set, and role-based access control through RestrictedConnection.

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 .sqlx

types and columns, not SQL validity

queries checked by the Rust type system

Async

native

native (on sqlx)

through diesel-async

Migrations

SQL files, sqlx-cli, migrate!

Rust code (sea-orm-migration)

SQL files, diesel_cli, embed_migrations!

Relations

hand-written joins

Related, loader, nested ActiveModels

joins and belonging_to

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.