SeaORM 2.0: Strongly-Typed Column
In our last post, we introduced a new Entity format - designed to be more concise, more readable, and easy to write by hand.
We've also added a new COLUMN constant to make it more ergonomic, along with other enhancements.
Bye-bye CamelCase
Previously, column names in queries had to be written in CamelCase. This was because the Column type was defined as an enum, it's simpler for the type system and faster to compile than generating a struct per column, but at the cost of losing column‑specific type information.
Our new design keeps compilation fast while restoring stronger type guarantees. As a bonus, it eliminates the need for CamelCase and even saves a keystroke.
// old
user::Entity::find().filter(user::Column::Name.contains("Bob"))
// new
user::Entity::find().filter(user::COLUMN.name.contains("Bob"))
// compile error: the trait `From<{integer}>` is not implemented for `String`
user::Entity::find().filter(user::COLUMN.name.like(2))
Under the hood, each Column value is wrapped in a byte-sized struct TypeAwareColumn. This wrapper is generic over Entity, so whether a table has 1 column or 100, the compile‑time cost stays roughly the same.
COLUMN Constant
pub struct NumericColumn<E: EntityTrait>(pub E::Column);
impl<E: EntityTrait> NumericColumn<E> {
pub fn eq<V>(v: V) -> Expr { .. }
}
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "user")]
pub struct Model {
..
}
// expands into following:
pub struct TypedColumn {
pub id: NumericColumn<Entity>,
pub name: StringColumn<Entity>,
pub date_of_birth: DateLikeColumn<Entity>,
}
pub const COLUMN: TypedColumn = TypedColumn {
id: NumericColumn(Column::Id),
name: StringColumn(Column::Name),
date_of_birth: DateLikeColumn(Column::DateOfBirth),
};
impl Entity {
pub const COLUMN: TypedColumn = COLUMN;
}
Type-Aware Helper Methods
Each column type wrapper exposes a set of methods that's relevant for the column's type. For example StringColumn::contains and ArrayColumn::contains are distinct methods that do the right thing!
Entity::COLUMN.name.contains("Bob") // WHERE "name" LIKE '%Bob%'
// tags is Vec<String>
Entity::COLUMN.tags.contains(vec!["awesome"]) // WHERE "tags" @> ARRAY ['awesome']
Right now there are a set of types: BoolColumn, NumericColumn, StringColumn, BytesColumn, JsonColumn, DateLikeColumn, TimeLikeColumn, DateTimeLikeColumn, UuidColumn, IpNetworkColumn, and more relevant methods can be added, feel free to make sugguestions.
Opt-in Only
These new structs are generated only when using the new #[sea_orm::model] or #[sea_orm::compact_model] macros. This keeps the change fully backwards‑compatible, and you incur no cost if you don't use them.
More Entity Enhancements
A big thanks to early-adopters who provided feedback to improve SeaORM 2.0.
Related Fields
The nested types for HasOne and HasMany have been changed from transparent type aliases to wrapper types. This makes it possible to distinguish between a relation that hasn’t been loaded and one that has loaded but yielded no models.
pub enum HasOne<E: EntityTrait> {
#[default]
Unloaded,
NotFound,
Loaded(Box<<E as EntityTrait>::ModelEx>),
}
pub enum HasMany<E: EntityTrait> {
#[default]
Unloaded,
Loaded(Vec<<E as EntityTrait>::ModelEx>),
}
We've added a range of methods to the wrapper type to make it feel as transparent as possible. The goal is to reduce friction while still preserving the benefits of a strong type system.
// len() / is_empty() methods
assert_eq!(users[0].posts.len(), 2);
assert!(!users[0].posts.is_empty());
// impl PartialEq
assert_eq!(users[0].posts, [post_1, post_2]);
// this creates HasOne::Loaded(Box<profile::ModelEx>)
profile: HasOne::loaded(profile::Model {
id: 1,
picture: "jpeg".into(),
..
})
Entity Loader Paginator
Entity Loader now supports pagination. It has the same API as a regular Select:
let paginator = user::Entity::load()
.with(profile::Entity)
.order_by_asc(user::COLUMN.id)
.paginate(db, 10);
let users: Vec<user::ModelEx> = paginator.fetch().await?;
Added delete_by_key
In addition to find_by_key, now the delete_by_key convenience method is also added:
user::Entity::delete_by_email("bob@spam.com").exec(db).await?
The delete_by_* methods now return DeleteOne instead of DeleteMany.
It doesn't change normal exec usage, but would change return type of exec_with_returning to Option<Model>:
fn delete_by_id<T>(values: T) -> DeleteMany<Self> // old
fn delete_by_id<T>(values: T) -> ValidatedDeleteOne<Self> // new
Self-Referencing Relations
Let's say we have a staff table, where each staff has a manager that they report to:
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "staff")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
pub reports_to_id: Option<i32>,
#[sea_orm(
self_ref,
relation_enum = "ReportsTo",
from = "reports_to_id",
to = "id"
)]
pub reports_to: HasOne<Entity>,
}
Entity Loader
let staff = staff::Entity::load()
.with(staff::Relation::ReportsTo)
.all(db)
.await?;
assert_eq!(staff[0].name, "Alan");
assert_eq!(staff[0].reports_to, None);
assert_eq!(staff[1].name, "Ben");
assert_eq!(staff[1].reports_to.as_ref().unwrap().name, "Alan");
assert_eq!(staff[2].name, "Alice");
assert_eq!(staff[2].reports_to.as_ref().unwrap().name, "Alan");
Model Loader
let staff = staff::Entity::find().all(db).await?;
let reports_to = staff
.load_self(staff::Entity, staff::Relation::ReportsTo, db)
.await?;
assert_eq!(staff[0].name, "Alan");
assert_eq!(reports_to[0], None);
assert_eq!(staff[1].name, "Ben");
assert_eq!(reports_to[1].unwrap().name, "Alan");
assert_eq!(staff[2].name, "Alice");
assert_eq!(reports_to[2].unwrap().name, "Alan");
It can work in reverse too.
let manages = staff
.load_self_rev(
staff::Entity::find().order_by_asc(staff::COLUMN.id),
staff::Relation::ReportsTo,
db,
)
.await?;
assert_eq!(staff[0].name, "Alan");
assert_eq!(manages[0].len(), 2);
assert_eq!(manages[0][0].name, "Ben");
assert_eq!(manages[0][1].name, "Alice");
Unix Timestamp Column Type
Sometimes it may be desirable (or no choice but) to store a timestamp as i64 in database, but mapping it to a DateTimeUtc in application code.
We've created a new set of UnixTimestamp wrapper types that does this transparently:
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "access_log")]
pub struct Model {
.. // with `chrono` crate
pub ts: ChronoUnixTimestamp,
pub ms: ChronoUnixTimestampMillis,
.. // with `time` crate
pub ts: TimeUnixTimestamp,
pub ms: TimeUnixTimestampMillis,
}
let now = ChronoUtc::now();
let log = access_log::ActiveModel {
ts: Set(now.into()),
..Default::default()
}
.insert(db)
.await?;
assert_eq!(log.ts.timestamp(), now.timestamp());
Entity-First Workflow
SchemaBuilder can now be used in migrations.
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
db.get_schema_builder()
.register(user::Entity)
.apply(db) // or sync(db)
.await
}
}
🧭 Instant GraphQL API
With Seaography, the Entities you wrote can instantly be exposed as a GraphQL schema, with full CRUD, filtering and pagination. No extra macros, no Entity re-generation is needed!
With SeaORM and Seaography, you can prototype quickly and stay in the flow. The Entity:
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
#[sea_orm(unique)]
pub email: String,
#[sea_orm(has_one)]
pub profile: HasOne<super::profile::Entity>,
#[sea_orm(has_many)]
pub posts: HasMany<super::post::Entity>,
}
Instantly turned into a GraphQL type:
type User {
id: Int!
name: String!
email: String!
profile: Profile
post(
filters: PostFilterInput
orderBy: PostOrderInput
pagination: PaginationInput
): PostConnection!
}
🖥️ SeaORM Pro: Admin Panel
SeaORM Pro is an admin panel solution allowing you to quickly and easily launch an admin panel for your application - frontend development skills not required, but certainly nice to have!
SeaORM Pro has been updated to support the latest features in SeaORM 2.0.
Features:
- Full CRUD
- Built on React + GraphQL
- Built-in GraphQL resolver
- Customize the UI with TOML config
- Role Based Access Control (new in 2.0)
🌟 Sponsors
Gold Sponsor
QDX pioneers quantum dynamics–powered drug discovery, leveraging AI and supercomputing to accelerate molecular modeling. We're grateful to QDX for sponsoring the development of SeaORM, the SQL toolkit that powers their data intensive applications.
GitHub Sponsors
If you feel generous, a small donation will be greatly appreciated, and goes a long way towards sustaining the organization.
A big shout out to our GitHub sponsors:
🦀 Rustacean Sticker Pack
The Rustacean Sticker Pack is the perfect way to express your passion for Rust. Our stickers are made with a premium water-resistant vinyl with a unique matte finish.
Sticker Pack Contents:
- Logo of SeaQL projects: SeaQL, SeaORM, SeaQuery, Seaography
- Mascots: Ferris the Crab x 3, Terres the Hermit Crab
- The Rustacean wordmark
Support SeaQL and get a Sticker Pack!

