When raw SQL stops scaling
You're building a service that handles user data. You started with raw SQL strings because it felt simple. Now you have a typo in a column name that crashes the app in production. You have a query that blocks the thread for 200 milliseconds, slowing down every other request. You want type safety. You want async. You want the compiler to catch your mistakes before they reach the server.
Sea-ORM fills that gap. It's an async Object-Relational Mapping library for Rust. It maps your database tables to Rust structs and lets you build queries using a fluent API. The queries compile to SQL. The results deserialize into your types. The whole process runs without blocking your async runtime.
What Sea-ORM actually does
Sea-ORM sits between your Rust code and the database driver. It uses sqlx under the hood for the actual database communication. Sea-ORM adds a layer of abstraction: entity definitions, a query builder, and active record patterns.
Think of Sea-ORM as a translator who speaks Rust types and SQL dialects fluently. You give it Rust expressions. It produces optimized SQL. It fetches the rows. It converts them back into Rust structs. The "async" part means the translator doesn't stand still while waiting for the database. It hands off the request and moves on to other work until the database responds.
Sea-ORM is modular. It doesn't bundle every database driver or every async runtime. You pick what you need via feature flags. This keeps your compile times fast and your binary small. It also means you have to be intentional about your setup.
Minimal setup
Start by adding the crate to your Cargo.toml. You must select a database driver and an async runtime. The features follow a pattern: sqlx-<database> and runtime-<runtime>-<tls>.
[dependencies]
# Select the database driver and async runtime via features.
# sqlx-postgres enables PostgreSQL support.
# runtime-tokio-rustls enables the Tokio runtime with Rustls for TLS.
sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls"] }
Convention aside: Sea-ORM mirrors sqlx feature names. If you switch from PostgreSQL to MySQL, you change sqlx-postgres to sqlx-mysql. If you switch from Tokio to Actix, you change the runtime feature. The feature names are consistent across the ecosystem. Stick to the sqlx-* and runtime-* prefixes when adding features.
Connect to the database in your async entry point. The connection function returns a pool, not a single connection.
use sea_orm::Database;
// The runtime macro must match the feature flag.
// #[tokio::main] requires runtime-tokio-*.
#[tokio::main]
async fn main() -> Result<(), sea_orm::DbErr> {
// Database::connect creates a connection pool.
// The URL format depends on the database driver.
let db = Database::connect("postgres://user:pass@localhost/dbname").await?;
// db is a DbConn, a handle to the pool.
// You can clone this handle and pass it to other tasks.
println!("Connected to database pool");
Ok(())
}
How the connection works
Database::connect initializes a connection pool. A pool manages a set of open connections to the database. When you run a query, Sea-ORM checks out a connection from the pool, executes the query, and returns the connection. You don't manage the checkout manually.
The db variable is a DbConn. It's a cheap handle to the pool. You can clone it and share it across threads or tasks. Under the hood, DbConn uses reference counting to keep the pool alive. When the last DbConn is dropped, the pool closes all connections.
The connection string format matches the database driver. For PostgreSQL, it's postgres://user:pass@host/db. For SQLite, it's sqlite://path/to/db.sqlite. The driver parses the string and configures the pool.
Pitfall: If your runtime macro doesn't match the feature flag, the code won't compile. #[tokio::main] requires a runtime-tokio-* feature. #[actix_web::main] requires a runtime-actix-* feature. Mismatching them triggers a compilation error about missing statics or trait bounds.
Pick the runtime feature that matches your #[main] macro. Mismatching them is the fastest way to get a linker error.
Realistic usage: querying data
To query data, you need an entity. An entity defines the structure of a table. Sea-ORM uses macros to generate the boilerplate code that links your struct to the database schema.
use sea_orm::{entity::prelude::*, DbConn, FromQueryResult};
use sea_orm::Database;
// DeriveEntityModel generates the Entity trait implementation.
// It maps the struct fields to database columns.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
}
// Relation enum defines foreign keys.
// Empty here means no relations.
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
// ActiveModelBehavior allows custom hooks.
// Empty implementation uses defaults.
impl ActiveModelBehavior for ActiveModel {}
// Fetch all users using the fluent API.
async fn fetch_users(db: &DbConn) -> Result<Vec<Model>, sea_orm::DbErr> {
// Model::find() starts a SELECT query.
// .all(db) executes the query and returns all rows.
Model::find().all(db).await
}
Convention aside: In production, don't write entities by hand. Use cargo install sea-orm-cli and run sea-orm-cli generate entity -u postgres://.... The CLI connects to your database and generates the entity code automatically. It keeps your code in sync with your schema. Hand-written entities drift from the database over time.
The fluent API builds queries step by step. Model::find() creates a builder for a SELECT query. You chain methods to add filters, ordering, and limits.
async fn find_user_by_name(db: &DbConn, name: &str) -> Result<Option<Model>, sea_orm::DbErr> {
// Chain filters to build a WHERE clause.
// Column::Name refers to the field defined in the entity.
Model::find()
.filter(Column::Name.eq(name))
// one() returns the first row, or None if empty.
.one(db)
.await
}
The compiler checks the chain. Each method returns a specific builder type. If you try to call a method that doesn't exist on the current builder, you get a type error. This catches mistakes early. The builder pattern ensures you can't accidentally execute a query without the necessary context.
Pitfalls and error handling
Sea-ORM returns Result<T, DbErr>. DbErr covers database errors, connection errors, and serialization errors. Handle it with ? or match on the variants.
Common errors include DbErr::RecordNotFound when a query returns no rows, and DbErr::Query for SQL errors. The error messages often include the raw SQL and the database error code.
Pitfall: Feature flags are strict. If you try to use a method that requires a feature you didn't enable, the compiler rejects the code. You'll see an error like E0277 (trait bound not satisfied) or a missing item error. The error message points to the feature you need. Add the feature to Cargo.toml and rebuild.
Pitfall: Connection pool exhaustion. If you hold connections open for too long or spawn too many concurrent queries, the pool runs out of connections. New queries wait until a connection is available. If the wait timeout expires, you get an error. Tune the pool size in your connection string or configuration.
Treat the connection pool as a shared resource. Pass &DbConn to your handlers. Don't create a new pool for every request.
Decision: when to use Sea-ORM
Use Sea-ORM when you want a fluent API with async support and don't mind macro-based entity generation. It balances type safety with developer ergonomics. The CLI tooling reduces boilerplate.
Use SQLx when you want compile-time checked SQL strings and zero abstraction over the query text. SQLx validates your SQL against the database at compile time. You write raw SQL, but the compiler checks the types and structure.
Use Diesel when you prefer a synchronous API or need a more mature ecosystem with fewer async dependencies. Diesel has a larger community and more plugins. The async support exists but is less integrated than Sea-ORM's native async design.
Use raw SQL when you have complex queries that ORMs struggle with, like recursive CTEs, vendor-specific optimizations, or dynamic queries that change structure at runtime. Raw SQL gives you full control. You lose type safety and portability.
Reach for Sea-ORM when you're building a standard CRUD service and want to move fast. Reach for SQLx when query correctness is paramount and you're willing to write more SQL.