How to use Sea-ORM

Sea-ORM is an async Rust ORM that uses a code-first approach where you define your database schema in Rust structs, then generate migration files and query builders automatically.

When the database lies to you

You're building a user service. You write a query to fetch users by email. You rename the column in PostgreSQL to email_address to match a new spec. Your Rust code compiles without warnings. You deploy. The server crashes at runtime with a "column not found" error.

This happens when your database schema and your Rust types live in separate worlds. You change one, forget the other, and the compiler can't help you. Sea-ORM solves this by making your Rust structs the source of truth. You define the schema in code. Sea-ORM generates the migration SQL and the query builder methods. If you rename a field in Rust, the compiler forces you to update the migration and every query that uses it. Mismatches become compile errors, not production incidents.

What Sea-ORM actually does

Sea-ORM is an async Object-Relational Mapper. It translates between Rust structs and database rows. The "code-first" approach means you write the Rust definition, and Sea-ORM derives the rest. It generates the SQL to create tables, the enums for columns, and the fluent query builder.

Think of it like a strict contract. Your Rust struct is the contract. The database must match the contract. If the contract changes, Sea-ORM generates the paperwork to update the database and updates the API so your code can't call methods that no longer exist. You get type safety, SQL injection protection, and async support without writing raw SQL strings.

The minimal setup

Start by adding dependencies. Sea-ORM requires a database driver and an async runtime. It doesn't include them by default so you can pick what fits your stack.

[dependencies]
# Core ORM with PostgreSQL driver and Tokio runtime
sea-orm = { version = "1", features = ["sqlx-postgres", "runtime-tokio-rustls"] }
# CLI tool for generating migrations from structs
sea-orm-migration = { version = "1", features = ["sqlx-postgres"] }

The sqlx-postgres feature pulls in the PostgreSQL driver. Swap to sqlx-mysql or sqlx-sqlite for other databases. The runtime-tokio-rustls feature binds Sea-ORM to Tokio with TLS support. If you use async-std, pick the corresponding runtime feature.

Define your entity in a module. The DeriveEntityModel macro does the heavy lifting.

use sea_orm::entity::prelude::*;

/// Represents a row in the user table.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "user")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub name: String,
    pub email: String,
}

/// Defines foreign key relations. Empty for a standalone table.
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

/// Hook for custom behavior on insert/update/delete.
impl ActiveModelBehavior for ActiveModel {}

The Model struct maps to a database row. DeriveEntityModel generates the Entity trait implementation, a Column enum with Id, Name, Email, and the ActiveModel struct for mutations. The Relation enum stays empty unless you add foreign keys. ActiveModelBehavior is a trait for side effects like auto-timestamping; leaving it empty is fine for basic use.

Convention aside: The community always imports sea_orm::entity::prelude::*. It brings DeriveEntityModel, ActiveModelBehavior, Column, EntityTrait, and other essentials into scope. It saves typing and keeps entity files consistent across projects.

How the macro transforms your code

When the compiler runs, DeriveEntityModel expands your struct into several types. You get a User struct that implements EntityTrait. This is the entry point for queries. You get a Column enum that lists every field. This enum prevents typos in query filters. You get ActiveModel, which wraps each field in a tri-state enum: Set(value), NotSet, or Unchanged.

The tri-state wrapper is the key design choice. It tells Sea-ORM exactly what changed. If you update a user's name, you set name: Set("Alice") and leave other fields as NotSet. Sea-ORM generates UPDATE user SET name = 'Alice' WHERE id = .... It doesn't touch the email column. This avoids overwriting concurrent changes, reduces network traffic, and prevents unnecessary triggers.

Trust the macro. If DeriveEntityModel compiles, your query builder is ready.

Generating migrations

You don't write SQL by hand. Sea-ORM generates migration files from your struct. Run the CLI tool to create a migration.

cargo sea-orm-cli generate migration --name init

This creates a migrations folder with a Rust file that contains the SQL to create the user table. The SQL matches your Model struct exactly. Primary keys, types, and column names are derived automatically.

Apply the migration to your database.

cargo sea-orm-cli migrate

The CLI connects to the database, checks the migration history table, and runs any pending migrations. It tracks which migrations have been applied so you don't run them twice.

Convention aside: Keep the migrations folder in version control. The generated Rust files are the source of truth for your schema history. If you edit the SQL manually, you risk drifting from the Rust struct. Let the struct drive the migration.

Realistic usage: connect, insert, query

In your application, connect to the database and use the generated types. The connection is async and pooled.

use sea_orm::{Database, DbConn, EntityTrait, QueryFilter};

/// Connects to the database, inserts a user, and queries them back.
#[tokio::main]
async fn main() -> Result<(), sea_orm::DbErr> {
    // Connect using the URL. Sea-ORM creates a connection pool.
    let db = Database::connect("postgres://user:pass@localhost/dbname").await?;

    // Create an ActiveModel with only the fields to set.
    let user = user::ActiveModel {
        name: Set("Alice".to_owned()),
        email: Set("alice@example.com".to_owned()),
        // Other fields default to NotSet.
        ..Default::default()
    };

    // Insert the row. Returns the Model with the generated ID.
    let inserted = user.insert(&db).await?;
    println!("Inserted user with ID: {}", inserted.id);

    // Build a query using the Column enum.
    let users = User::find()
        .filter(user::Column::Name.eq("Alice"))
        .all(&db)
        .await?;

    println!("Found {} users", users.len());
    Ok(())
}

Database::connect returns a DbConn that wraps a connection pool. You pass this connection to every query. user::ActiveModel uses the Set wrapper for fields you want to insert. ..Default::default() fills the rest with NotSet. The insert method executes the SQL and returns a Model with the database-generated values like id.

User::find() starts a query builder. filter adds a WHERE clause using the Column enum. This is type-safe. If you typo the column name, the compiler rejects it. all executes the query and returns a Vec<Model>.

Don't fight the Set wrapper. It's the only way Sea-ORM knows what to update.

Pitfalls and compiler errors

Sea-ORM catches many errors at compile time, but some slip through.

If you forget to derive the required traits, the compiler rejects your code with E0277 (trait bound not satisfied). The macro generates code that requires EntityTrait and ActiveModelTrait. Without the derives, those traits aren't implemented. Always include DeriveEntityModel and DeriveRelation.

If you edit the database schema manually without updating the Rust struct, you get runtime errors. Sea-ORM doesn't monitor the database. If you add a column in psql but forget the struct, queries might fail with a DbErr::Query error. Treat the Rust struct as the single source of truth. Update the struct first, then generate the migration.

If you try to insert a row with all fields NotSet, the database rejects it. Sea-ORM generates an INSERT with no values, which violates SQL syntax. The error comes back as DbErr::Query. Always set at least the non-nullable fields.

If you use User::Column::Name instead of user::Column::Name, the code compiles but might be confusing. The convention is to use the lowercase module name user::Column::Name because the macro generates the column enum inside the module. Stick to the module path for clarity.

When to use Sea-ORM

Use Sea-ORM when you want type-safe queries without writing raw SQL and you're building a standard CRUD application. Use Sea-ORM when you need async support and want to switch between PostgreSQL, MySQL, and SQLite with minimal code changes. Use Sea-ORM when you prefer a code-first workflow where Rust structs drive migrations and query generation.

Reach for raw SQL via sqlx when you have complex analytical queries that the query builder struggles to express efficiently. Use diesel when you prefer a compile-time checked SQL DSL that feels more like writing SQL in Rust and you don't need the async flexibility. Use sqlx directly when you want zero-abstraction performance and full control over the query plan.

Treat the ActiveModel enum as your change log. If a field isn't Set, the database doesn't see it.

Where to go next