When raw SQL meets Rust types
You're building a web service. The database is set up. You've written three raw SQL queries by hand, and your fingers are cramping from escaping strings and casting i32 from Option<String>. You want the type safety of Rust without the boilerplate of a query builder. You also need async because blocking the thread on I/O is a no-go in a high-throughput server. SeaORM is the tool that bridges that gap. It maps your database schema to Rust structs, handles the connection pooling, and lets you write queries that the compiler checks before they ever hit the database.
SeaORM in plain words
SeaORM is an Object-Relational Mapper, or ORM. In plain terms, it's a translator between your Rust code and your database. You define your data as Rust structs. SeaORM generates the SQL to talk to the database. When the database sends back rows, SeaORM converts them back into those structs.
The magic happens at compile time. SeaORM uses macros to generate the glue code. This means if you rename a field in your struct, the compiler catches the mismatch before you run the code. You get the flexibility of an ORM with the safety guarantees of Rust's type system. SeaORM also supports async natively. It works with tokio or async-std out of the box. You write await on your queries, and SeaORM handles the non-blocking I/O.
Treat the macro as a contract. If the struct changes, the SQL changes. The compiler is your safety net.
Minimal entity definition
Every SeaORM project starts with an entity. An entity maps a database table to a Rust struct. You use the DeriveEntityModel macro to generate the necessary trait implementations.
use sea_orm::entity::prelude::*;
// DeriveEntityModel generates the Entity trait and column enums.
// This tells SeaORM how to map this struct to the database.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
}
// DeriveRelation defines foreign keys.
// An empty enum means this table has no relations.
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
// ActiveModelBehavior allows hooks for inserts, updates, and deletes.
// An empty implementation means use the default behavior.
impl ActiveModelBehavior for ActiveModel {}
The Model struct represents a row in the users table. The #[sea_orm(primary_key)] attribute marks the id field as the primary key. SeaORM uses this information to generate INSERT, UPDATE, and DELETE statements. The Relation enum is empty here because the users table has no foreign keys. If you add a posts table with a user_id column, you'd define a relation in the Relation enum.
Convention is to keep Model read-only. SeaORM generates a separate ActiveModel struct for mutations. ActiveModel tracks which fields have changed. This allows SeaORM to generate efficient SQL that only updates modified columns.
Keep your entity definitions clean. The macro generates a lot of code. Cluttering the struct with logic breaks the pattern.
How queries work
SeaORM provides a query builder that mirrors SQL syntax. You chain methods to construct the query. Each method returns a new query object. This immutable builder pattern prevents state bugs.
use sea_orm::entity::prelude::*;
#[tokio::main]
async fn main() -> Result<(), DbErr> {
// Connect to an in-memory SQLite database.
// In production, use a connection string like "postgres://user:pass@localhost/db".
let db = Database::connect("sqlite::memory:").await?;
// Create the table for this example.
// In production, use migrations instead of raw SQL.
db.execute(Statement::from_string(
db.get_database_backend(),
"CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)".to_string(),
)).await?;
// Insert a user using ActiveModel.
// Set marks the field as changed. Unset leaves it alone.
let new_user = ActiveModel {
name: Set("Alice".to_owned()),
id: Unset,
};
new_user.insert(&db).await?;
// Query all users.
// find() starts a SELECT query. all() executes and returns a Vec.
let users = Model::find().all(&db).await?;
println!("Found {} users", users.len());
// Filter users by name.
// filter() adds a WHERE clause. Column::Name is generated by the macro.
let alice = Model::find()
.filter(Column::Name.eq("Alice"))
.one(&db)
.await?;
println!("Alice: {:?}", alice);
Ok(())
}
The ActiveModel struct uses the ActiveValue enum to track changes. Set(value) tells SeaORM to write this value to the database. Unset tells SeaORM to skip this field entirely. When you call update, SeaORM generates an SQL statement that only includes fields marked as Set. This prevents accidental overwrites and reduces network traffic.
Convention is to use ActiveModel::from(model) when updating an existing record. This ensures you start with a clean slate where all fields are Unset. Manually constructing an ActiveModel risks leaving fields as NotSet or Set by accident. The community also prefers explicit Set calls over bulk assignment for updates. It makes the intent clear. Readers can see exactly which fields changed.
Always handle DbErr. Database operations can fail. Ignoring the result with unwrap turns a recoverable error into a panic.
Realistic usage patterns
In a real application, you'll use migrations, relations, and connection pooling. SeaORM includes a CLI tool for migrations. You define migrations in Rust code. The tool generates the SQL and applies it to the database. This keeps your schema version-controlled and reproducible.
// Migration example structure.
// You run `sea-orm-cli migrate up` to apply migrations.
// This integrates with CI/CD pipelines and ensures schema consistency.
use sea_orm_migration::prelude::*;
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m20240101_000001_create_users"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(User::Table)
.if_not_exists()
.col(ColumnDef::new(User::Id).integer().not_null().auto_increment().primary_key())
.col(ColumnDef::new(User::Name).string().not_null())
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(User::Table).to_owned())
.await
}
}
Relations let you fetch associated data. If you have a Post entity with a user_id foreign key, you can define a relation in the Relation enum.
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::post::Entity")]
Posts,
}
You can then use find_related to fetch posts for a user. SeaORM handles the join and the mapping.
// Fetch a user and all their posts in one query.
// find_related generates a JOIN and maps the results.
let user_with_posts = User::find()
.filter(Column::Id.eq(1))
.find_related(Post)
.all(&db)
.await?;
This keeps your code clean and type-safe. You don't write raw JOIN clauses. SeaORM generates them based on the relation definitions.
Check your feature flags first. Half of SeaORM compilation errors are missing features, not logic bugs. SeaORM requires you to enable database drivers via features. If you try to connect to PostgreSQL but haven't enabled sqlx-postgres, the compiler rejects you with E0277 (trait bound not satisfied). The error message points to the missing feature. Always verify your Cargo.toml features match your database.
Pitfalls and compiler errors
SeaORM enforces strict boundaries between read and write operations. You cannot insert a Model. You must use ActiveModel. The compiler enforces this distinction. If you try to call insert on a Model, you get a trait bound error. Model doesn't implement the IntoActiveModel trait required for mutations.
Another common issue is connection string format. SeaORM delegates connection management to sqlx or diesel under the hood. The connection string format depends on the driver. SQLite uses sqlite::memory: or sqlite:./file.db. PostgreSQL uses postgres://user:pass@host/db. MySQL uses mysql://user:pass@host/db. If the format is wrong, the connection fails at runtime. SeaORM returns a DbErr. Handle this error gracefully. Log the error and return a meaningful response to the client.
Async runtimes must match. SeaORM requires a runtime feature. You must enable runtime-tokio-rustls or runtime-tokio-native-tls or runtime-async-std-rustls. If you use tokio in your app but enable runtime-async-std, the event loops won't match. The code might compile, but queries will hang or panic. Stick to one runtime. Convention is tokio with rustls for TLS. It's the most common stack in the Rust ecosystem.
Trust the borrow checker. It usually has a point. If the compiler complains about lifetimes in your query builder, check your references. SeaORM's query builder borrows the database connection. Ensure the connection lives long enough for the query to execute.
When to use SeaORM
Use SeaORM when you want a full-featured ORM with async support and you're comfortable with macros. It handles migrations, relations, and complex queries while keeping type safety. Use SQLx when you prefer writing raw SQL but want compile-time checking. SQLx verifies your queries against the database schema during compilation. It gives you more control over the SQL but requires you to write the queries yourself. Use Diesel when you need a synchronous ORM or you're working on a project that doesn't require async. Diesel has a mature ecosystem and excellent query builder, but it lacks native async support. Use raw SQL when performance is the absolute bottleneck and you need to hand-tune every query. ORM abstractions add overhead. If profiling shows the ORM is the bottleneck, drop down to raw SQL for those specific hot paths.
Pick the tool that matches your team's SQL literacy. SeaORM abstracts SQL. SQLx exposes it. Choose based on how much control you need.