Entity First Workflow
2.0.0What's Entity first?
SeaORM used to adopt a schema‑first approach: meaning you design database tables and write migration scripts first, then generate entities from that schema.
Entity‑first flips the flow: you hand-write the entity files, and let SeaORM generates the tables and foreign keys for you.
All you have to do is to add the following to your main.rs right after creating the database connection:
let db = &Database::connect(db_url).await?;
// synchronizes database schema with entity definitions
db.get_schema_registry("my_crate::entity::*").sync(db).await?;
This requires two feature flags schema-sync and entity-registry, and we're going to explain what they do.
Entity Registry
The "my_crate::entity::*" must match the name of your crate in Cargo.toml:
[package]
name = "my_crate"
Alternatively, you can do the following to get the current crate:
// This returns the caller's crate
db.get_schema_registry(module_path!().split("::").next().unwrap())
The above function get_schema_registry unfolds into the following:
db.get_schema_builder()
.register(comment::Entity)
.register(post::Entity)
.register(profile::Entity)
.register(user::Entity)
.sync(db)
.await?;
You might be wondering: how can SeaORM recognize my entities when, at compile time, the SeaORM crate itself has no knowledge of them?
Rest assured, there's no source‑file scanning or other hacks involved - this is powered by the brilliant inventory crate. The inventory crate works by registering items (called plugins) into linker-collected sections.
At compile-time, each Entity module registers itself to the global inventory along with their module paths and some metadata. On runtime, SeaORM then filters the Entities you requested and construct a SchemaBuilder.
The EntityRegistry is completely optional and just adds extra convenience, it's perfectly fine for you to register Entities manually like above.
Resolving Entity Relations
If you remember from the previous post, you'll notice that comment has a foreign key referencing post. Since SQLite doesn't allow adding foreign keys after the fact, the post table must be created before the comment table.
This is where SeaORM shines: it automatically builds a dependency graph from your entities and determines the correct topological order to create the tables, so you don't have to keep track of them in your head.
Schema Sync in Action
The second feature, schema-sync, compares the in‑memory entity definitions with the live database schema, detects missing tables, columns, and keys, and creates them idempotently - no matter how many times you run sync, the schema converges to the same state.
Let's walk through the different scenarios:
Adding Table
Let's say you added a new Entity under mod.rs
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0.0-rc.14
pub mod prelude;
pub mod post;
pub mod upvote; // ⬅ new entity module
..
The next time you cargo run, you'll see the following:
CREATE TABLE "upvote" ( "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, .. )
This will create the table along with any foreign keys.
Adding Columns
use sea_orm::entity::prelude::*;
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "profile")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub picture: String,
pub date_of_birth: Option<DateTimeUtc>, // ⬅ new column
..
}
impl ActiveModelBehavior for ActiveModel {}
The next time you cargo run, you'll see the following:
ALTER TABLE "profile" ADD COLUMN "date_of_birth" timestamp with time zone
How about adding a non-nullable column? You can set a default_value or default_expr:
#[sea_orm(default_value = 0)]
pub post_count: i32,
// this doesn't work in SQLite
#[sea_orm(default_expr = "Expr::current_timestamp()")]
pub updated_at: DateTimeUtc,
Rename Column
If you only want to rename the field name in code, you can simply remap the column name:
pub struct Model {
..
#[sea_orm(column_name = "date_of_birth")]
pub dob: Option<DateTimeUtc>, // ⬅ renamed for brevity
}
This doesn't involve any schema change.
If you want to actually rename the column, then you have to add a special attribute. Note that you can't simply change the field name, as this will be recognized as adding a new column.
pub struct Model {
..
#[sea_orm(renamed_from = "date_of_birth")] // ⬅ special annotation
pub dob: Option<DateTimeUtc>,
}
The next time you cargo run, you'll see the following:
ALTER TABLE "profile" RENAME COLUMN "date_of_birth" TO "dob"
Nice, isn't it?
Add Foreign Key
Let's create a new table with a foreign key:
use sea_orm::entity::prelude::*;
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "upvote")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub post_id: i32,
#[sea_orm(belongs_to, from = "post_id", to = "id")]
pub post: HasOne<super::post::Entity>,
..
}
impl ActiveModelBehavior for ActiveModel {}
The next time you cargo run, you'll see the following:
CREATE TABLE "upvote" (
"post_id" integer NOT NULL PRIMARY KEY,
..
FOREIGN KEY ("post_id") REFERENCES "post" ("id")
)
If however, the post relation is added after the table has been created, then the foreign key couldn't be created for SQLite. Relational queries would still work, but functions completely client-side.
Add Unique Key
Now, let's say we've forgotten to add a unique constraint on user name:
use sea_orm::entity::prelude::*;
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)] // ⬅ add unique key
pub name: String,
#[sea_orm(unique)]
pub email: String,
..
}
The next time you cargo run, you'll see the following:
CREATE UNIQUE INDEX "idx-user-name" ON "user" ("name")
As mentioned in the previous blog post, you'll also get a shorthand method generated on the Entity:
user::Entity::find_by_name("Bob")..
Remove Unique Key
Well, you've changed your mind and want to remove the unique constraint on user name:
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
// no annotation
pub name: String,
#[sea_orm(unique)]
pub email: String,
..
}
The next time you cargo run, you'll see the following:
DROP INDEX "idx-user-name"
Footnotes
Note that in general schema sync would not attempt to do any destructive actions, so meaning no DROP on tables, columns and foreign keys. Dropping index is an exception here.
Every time the application starts, a full schema discovery is performed. This may not be desirable in production, so sync is gated behind a feature flag schema-sync that can be turned off based on build profile.
Using SchemaBuilder in migrations
You can also use SchemaBuilder inside 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(note::Entity)
.apply(db)
.await
}
}
The difference between apply and sync is, sync always check that if the tables / columns already existed, while apply does not. So apply is intended for the initialization step.
Because the migration system already prevents applying a migration step twice, it's fine to use apply inside migrations.
To ensure that the migrations can always be applied in order, one by one, you can create a "time capsule" for the initial migration, where you preserve a copy of the initial version of the entities in a submodule.