SeaORM 2.0 entities, ActiveModel, and a tested CRUD round trip
Let the struct write the table, and let each field say whether it is sent.
The problem
A backend service that stores notes, orders or user profiles has to write those records to a database and read them back. The plain approach is a Rust struct for the data plus hand-written SQL strings beside it, and the two slowly drift apart. Rename the field title to heading and the compiler is perfectly happy, because every query is just text inside a string literal. The mistake surfaces at runtime, in whichever request first reaches that statement, and the complaint comes from the database instead of from your build. Adding a column does the same damage in every INSERT that lists its columns by hand.
The second pain is the update that rewrites everything. A handler loads a row, changes one field in memory, and saves the whole row back with every column in the SET clause. If another request changed a different column between your read and your write, for example by pinning the note, your save puts the old value back and nobody is told. The service needs an UPDATE that names only the column it meant to change. It also needs a clear error when the row it aimed at no longer exists, so that a missing record becomes a proper "not found" answer instead of a silent no-op or a crash.
The idea, in plain words
SeaORM is an async ORM for Rust. An ORM, short for object-relational mapper, is a library that maps database tables onto types in your programming language. You describe a table once, as a Rust struct called the entity, and the library derives the column names, the CREATE TABLE statement and the query helpers from that single description. For writing, you fill in a companion struct called an ActiveModel, in which every field carries one of three labels: "I set this value", "this value came from the database and is untouched", or "I said nothing about this field". Only the first label produces SQL, so an update touches exactly the columns you chose and an insert lets the database invent the id. CRUD is short for create, read, update and delete, the four things a service does to a row. Because the tests run against SQLite kept in memory, with no server or container to start, the whole create, read, update, delete cycle runs under a plain cargo test.
Concept A: the entity is the contract
An entity is the Rust description of one database table. You write a struct named Model with one field per column, and the DeriveEntityModel macro generates, in the words of the SeaORM docs, "an Entity with associating Model, Column and PrimaryKey". A derive macro is code the compiler expands from your struct at build time. The Model is the plain struct that holds one row's values as they were read from the database. The derive also produces a Column enum with one variant per column, so note::Column::Pinned is a real Rust name instead of the text "pinned".
The reason this matters is that the struct becomes the only place the table is described. Rename a field and the generated names change everywhere at once, so any query still using the old name stops compiling. Without that contract the column list lives in a string, and nothing connects it to the struct until the statement actually runs.
The tested setup was macOS, rustc and cargo 1.99.0, sea-orm 2.0.4 and tokio 1. The dependencies are:
[dependencies]
sea-orm = { version = "2.0.4", features = ["sqlx-sqlite", "runtime-tokio-rustls", "macros"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
The entity lives in src/lib.rs:
pub mod note {
use sea_orm::entity::prelude::*;
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "note")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
pub body: Option<String>,
pub pinned: bool,
}
impl ActiveModelBehavior for ActiveModel {}
}
The table_name = "note" attribute names the table this struct describes. The field marked #[sea_orm(primary_key)] is the primary key, the column whose value identifies each row uniquely, and the docs state that auto_increment is implied for it, so the database numbers new rows by itself. Column types follow the Rust types by default: i32 becomes integer and String becomes varchar. Wrapping a type in Option, as body: Option<String> does, makes the column nullable, meaning it can hold NULL, while the unwrapped fields become NOT NULL. The #[sea_orm::model] attribute above the derive is the 2.0 entity format from the docs, and in this setup it is what supplies the entity's relation definitions. Delete that one line and the 2.0.4 build stops with error[E0425] ("cannot find type Relation in this scope"), even though this entity declares no relations at all; putting the line back fixes it. Finally, impl ActiveModelBehavior for ActiveModel {} looks like dead code, yet the docs say not to delete it even when empty. It is the place where the hooks before_save, after_save, before_delete and after_delete live. The tests below reach this module as notes_seaorm::note, which means the package in this project is named notes_seaorm.
The test file starts with these imports:
use notes_seaorm::note;
use sea_orm::{entity::*, query::*, ActiveValue::{NotSet, Set, Unchanged}, Database, DbBackend, DbErr, Schema};
The first test function begins by asking SeaORM for the CREATE TABLE statement that matches the entity and printing it as SQLite would see it:
let schema = Schema::new(DbBackend::Sqlite);
let stmt = schema.create_table_from_entity(note::Entity);
println!("CREATE: {}", DbBackend::Sqlite.build(&stmt));
The real output is:
CREATE: CREATE TABLE "note" ( "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "title" varchar NOT NULL, "body" varchar, "pinned" boolean NOT NULL )
Schema::new(DbBackend::Sqlite) picks the SQL dialect, and create_table_from_entity turns the entity into a statement object without touching any database. Reading the output against the struct shows the whole mapping. The primary key became integer NOT NULL PRIMARY KEY AUTOINCREMENT. The String title became varchar NOT NULL. The Option<String> body became a bare varchar with no NOT NULL, so it accepts NULL. The bool became boolean NOT NULL. Nothing here was typed as SQL by hand, so the table cannot disagree with the struct.
**Common mistake: expecting indexes from create_table_from_entity.**
- Symptom: the printed statement contains only a CREATE TABLE, and a column you expected to be indexed has no index in the database.
- Cause: the docs say that since version 0.7.0
Schema::create_table_from_entityno longer creates indexes. It produces the table and its columns, nothing more. - Fix: treat the generated statement as the table definition only, and create any index through your own schema step. Production applications usually manage schema with migrations, which this article does not cover.
Concept B: ActiveValue has three states, and each one decides the SQL
A Model only holds finished values, so it cannot express "leave this column alone". An ActiveModel is the write-side twin of the Model. As the docs put it, it "has all the attributes of Model wrapped in ActiveValue". An ActiveValue is an enum with three states:
Setis "a Value that's explicitly set by the application and sent to the database".Unchangedis "an existing, unchanged Value from the database". You get these when you query an existing Model and convert it into an ActiveModel.NotSetis "an undefined Value. Nothing is sent to the database." A freshly created ActiveModel starts with every field NotSet.
Without three states you could not tell two very different intentions apart. Leaving the id to the database and writing NULL into a column are not the same request, and the docs call this out directly: NotSet is "different from Set(None)", which explicitly sets the column to NULL. Without an Unchanged state, an update would have to resend every column, which is the clobbering problem from the start of this article. The docs say the generated query for a save "will only include the Set attributes".
The cheapest way to see the SQL is to build the statements without a database. First the INSERT:
let ins = Insert::one(note::ActiveModel {
id: NotSet,
title: Set("first".to_owned()),
body: Set(None),
pinned: Set(false),
})
.build(DbBackend::Sqlite)
.to_string();
println!("INSERT: {ins}");
Real output:
INSERT: INSERT INTO "note" ("title", "body", "pinned") VALUES ('first', NULL, FALSE)
The id column is missing from the column list because its field was NotSet, which leaves the database free to assign the number. The body column is present with the value NULL because Set(None) is an explicit instruction. The .build(DbBackend::Sqlite).to_string() tail renders the statement as SQL text with the values inlined, which is useful for a test and for learning. Compare the two fields side by side: id was omitted from the statement entirely, while body was written as NULL, and the difference came only from choosing NotSet or Set(None).
Now the UPDATE:
let upd = Update::one(note::ActiveModel {
id: Unchanged(1),
title: NotSet,
body: NotSet,
pinned: Set(true),
})
.validate()
.unwrap()
.build(DbBackend::Sqlite)
.to_string();
println!("UPDATE: {upd}");
Real output:
UPDATE "note" SET "pinned" = TRUE WHERE "note"."id" = 1
Only pinned appears in the SET clause, because it is the only Set field. The title and body are NotSet, so they are not rewritten, and a concurrent change to either survives. The Unchanged primary key is not written either. It moves into the WHERE clause and selects the row. The .validate() call is required on Update::one in 2.0, according to the comment in the docs, and it returns a Result, which the test unwraps. The compiler enforces this: with the two lines removed, the 2.0.4 build fails with error[E0599] ("no method named build found for struct UpdateOne<A>"), because only the validated statement can be built into SQL. If you see that error while porting code written for an older SeaORM, add .validate()? before .build(...).
Common mistake: converting a half-filled ActiveModel back into a Model.
let half = note::ActiveModel { id: Set(1), title: NotSet, body: Set(None), pinned: Set(false) };
println!("TRY_INTO: {:?}", half.try_into_model());
- Symptom: the call returns
Err(AttrNotSet("title")), naming the first field that has no value. That is the real output. - Cause:
try_into_model"will fail if any of the fields of the ActiveModel are NotSet". A Model has no way to represent an undefined column, so the conversion refuses instead of inventing a value. - Fix: set the field the error names, or avoid the conversion by keeping the Model that
insertorupdatealready returned to you.
Concept C: the round trip against a real database
A round trip here means creating a row, reading it, changing it and deleting it through a live connection, then checking what the database says at each step. The SQL-printing test proves the statements have the right shape, but only a real engine can show the generated id, the filter results or the failure of an update on a missing row. Without that, those behaviors stay guesses. The test connects to in-memory SQLite, a SQLite database that exists only in the test process's memory. The docs note that it "is quick to run and easy to write tests" and that you "don't have to setup Docker container". They also warn that it is "not a substitute to integration testing against the target database, but a supplement".
The first part connects, creates the table and inserts two rows:
#[tokio::test]
async fn crud_round_trip_in_memory() -> Result<(), DbErr> {
let db = Database::connect("sqlite::memory:").await?;
let schema = Schema::new(DbBackend::Sqlite);
db.execute(&schema.create_table_from_entity(note::Entity)).await?;
let a = note::ActiveModel { title: Set("buy milk".into()), body: Set(None), pinned: Set(false), ..Default::default() }
.insert(&db).await?;
let b = note::ActiveModel { title: Set("ship release".into()), body: Set(Some("seaorm".into())), pinned: Set(true), ..Default::default() }
.insert(&db).await?;
println!("INSERTED: {a:?} | {b:?}");
Real output:
INSERTED: Model { id: 1, title: "buy milk", body: None, pinned: false } | Model { id: 2, title: "ship release", body: Some("seaorm"), pinned: true }
The test function is async and returns Result<(), DbErr>, so the ? operator turns any database error into a failed test. Database::connect("sqlite::memory:") opens the connection, and db.execute runs the CREATE TABLE statement from Concept A. Each ActiveModel sets title, body and pinned and leaves the rest to ..Default::default(), which means id stays NotSet. The .insert(&db) call then returns the inserted Model, with the id the database chose filled in: 1 and 2 here. You never wrote an id, yet you hold it afterwards, and that is what a handler needs to put in a response.
Next comes a read with a filter:
let pinned = note::Entity::find().filter(note::Column::Pinned.eq(true)).all(&db).await?;
println!("PINNED: {pinned:?}");
PINNED: [Model { id: 2, title: "ship release", body: Some("seaorm"), pinned: true }]
find() starts a query on the entity, filter adds a condition built from the Column enum, and all runs it and returns a Vec of Models. Only the pinned note comes back. Because the condition uses note::Column::Pinned and not a string, a rename of the field would break this line at compile time.
The update follows. It starts from the Model a and walks through the state labels:
let mut am: note::ActiveModel = a.clone().into();
println!("CHANGED_BEFORE: {}", am.is_changed());
am.set_if_not_equals(note::Column::Title, "buy milk".into());
println!("CHANGED_SAME_VALUE: {}", am.is_changed());
am.title = Set("buy oat milk".into());
let a2 = am.update(&db).await?;
println!("UPDATED: {a2:?}");
CHANGED_BEFORE: false
CHANGED_SAME_VALUE: false
UPDATED: Model { id: 1, title: "buy oat milk", body: None, pinned: false }
Converting a Model with .into() gives an ActiveModel whose every field is Unchanged, so is_changed(), which checks whether any field is Set, reports false. Writing the same title through set_if_not_equals keeps the field Unchanged, so the answer stays false and no pointless write is queued. Assigning Set("buy oat milk".into()) to the title is a real change, and am.update(&db) sends an UPDATE that includes only the Set column, as the docs state, with the primary key in the WHERE clause. The returned Model shows the new title with the other columns intact.
Common mistake 1: a helper name that the docs page spells differently.
The build stops with this real compiler output when the call is written the way the docs page spells it:
error[E0599]: no method named `set_if_not_equal` found for struct `ActiveModel` in the current scope
--> tests/crud.rs:53:8
help: there is a method `set_if_not_equals` with a similar name
- Symptom: error E0599, "no method named
set_if_not_equal", with a help line pointing atset_if_not_equals.
- Cause: the docs page says you can use
set_if_not_equalto preserve the Unchanged state when the new value matches the old one, but the crate source at the tested version definesset_if_not_equals, with a trailing s. It exists twice: onActiveModelTrait, taking a column and aValue, and onActiveValue, taking just the value. - Fix: use the spelling the compiler suggests. The output above shows the payoff, since
CHANGED_SAME_VALUEstays false after writing the identical title. When a docs page and the compiler disagree, the compiler and the crate source win; the docs page has an "Edit this page" link to its source on GitHub, so a one-word correction like this one is also an easy first contribution to the project.
Now the update of a row that does not exist:
let ghost = note::ActiveModel { id: Unchanged(99), title: Set("ghost".into()), body: NotSet, pinned: NotSet };
println!("GHOST_UPDATE: {:?}", ghost.update(&db).await);
GHOST_UPDATE: Err(RecordNotUpdated)
Common mistake 2: treating a missing row as a server failure.
- Symptom: an update returns
Err(RecordNotUpdated), and a handler that bubbles every error up answers with a 500 or panics. - Cause: the UPDATE matched no row, because no note with id 99 exists. The client may hold a stale id, or another request may have deleted the row first.
- Fix: inspect the error. The
RecordNotUpdatedvariant ofDbErrmeans "the thing you asked to change is not there", so answer with HTTP 404. Reserve 500 for the other database errors.
The last part deletes and counts:
let res = note::Entity::delete_by_id(b.id).exec(&db).await?;
println!("DELETED rows_affected={}", res.rows_affected);
println!("AFTER_DELETE: {:?}", note::Entity::find_by_id(b.id).one(&db).await?);
println!("COUNT: {}", note::Entity::find().count(&db).await?);
Ok(())
}
DELETED rows_affected=1
AFTER_DELETE: None
COUNT: 1
delete_by_id deletes "a row directly by its primary key, without selecting the Model first", so there is no read before the write. The result's rows_affected is the number of rows the statement removed, and 1 confirms the row existed. find_by_id(...).one(&db) then returns None, and count reports that one note, the milk one, is left. Running cargo test -- --nocapture --test-threads=1 prints everything together, where --nocapture shows the println! lines and --test-threads=1 runs the two tests one after the other:
running 2 tests
test crud_round_trip_in_memory ... INSERTED: Model { id: 1, title: "buy milk", body: None, pinned: false } | Model { id: 2, title: "ship release", body: Some("seaorm"), pinned: true }
PINNED: [Model { id: 2, title: "ship release", body: Some("seaorm"), pinned: true }]
CHANGED_BEFORE: false
CHANGED_SAME_VALUE: false
UPDATED: Model { id: 1, title: "buy oat milk", body: None, pinned: false }
GHOST_UPDATE: Err(RecordNotUpdated)
DELETED rows_affected=1
AFTER_DELETE: None
COUNT: 1
ok
test schema_and_sql_shapes ... CREATE: CREATE TABLE "note" ( "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "title" varchar NOT NULL, "body" varchar, "pinned" boolean NOT NULL )
INSERT: INSERT INTO "note" ("title", "body", "pinned") VALUES ('first', NULL, FALSE)
UPDATE: UPDATE "note" SET "pinned" = TRUE WHERE "note"."id" = 1
TRY_INTO: Err(AttrNotSet("title"))
ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
Putting it together
Follow the note titled "buy milk" from birth to the end of the test. It is inserted with an ActiveModel whose id is NotSet and whose body is Set(None), so the database assigns id 1 and stores NULL, and the Model that comes back already carries that id. A filter on Column::Pinned finds only the other note, and a typo in that column name would have been a compile error, which answers the first half of the problem. When the title is renamed to "buy oat milk", the row travels as a Model, becomes an ActiveModel with every field Unchanged, and gets exactly one Set field. The UPDATE therefore rewrites only the title and filters on the primary key, so a request that pinned the note a moment earlier is not overwritten, which answers the second half.
An update aimed at id 99 fails with RecordNotUpdated, which a web handler can turn into a 404 instead of a crash. Deleting the "ship release" note by id reports rows_affected=1, the lookup returns None, and the count falls to 1, which is the milk note. All of it ran in a database that lives and dies inside the test process, so the checks are fast and need nothing installed. Keep in mind the docs' caveat that in-memory SQLite supplements, and does not replace, testing against the database you deploy on.
Check yourself
- **What does
body: Set(None)produce in an INSERT, and what doesid: NotSetproduce?**Set(None)writes an explicit NULL into the body column.NotSetleaves the column out of the statement, so the database generates the id.
- **In
Update::onewithid: Unchanged(1)andpinned: Set(true), which columns appear in SET, and where does the id go?** Onlypinnedappears in SET. The Unchanged primary key goes into the WHERE clause, and.validate()must be called first in 2.0.
- **Why does
try_into_modelreturnErr(AttrNotSet("title"))on a half-filled ActiveModel?** Any NotSet field makes the conversion fail, because a Model cannot represent an undefined value. Set the named field or keep the Model returned byinsert.
- **An update on a nonexistent id returns
Err(RecordNotUpdated). How should a web handler react?** Treat it as "not found" and answer with a 404 instead of a server error.
- **Which two things does the 2.0.4 crate teach you about
set_if_not_equalsandcreate_table_from_entity?** The helper is spelled with a trailing s, whatever the docs page says, and it leavesis_changed()false when the value is the same. The table builder creates no indexes and has not since 0.7.0.
Sources
- SeaORM docs, "Entity Format"
- SeaORM docs, "ActiveModel"
- SeaORM docs, "Update"
- SeaORM docs, "Insert"
- SeaORM docs, "Delete"
- SeaORM docs, "Create Table"
- SeaORM docs, "Using SQLite" (Writing Tests)
- sea-orm 2.0.4 crate source (set_if_not_equals on ActiveModelTrait and ActiveValue)
- SeaORM documentation source on GitHub (the "Edit this page" target for the ActiveModel page)