Reusable model bases
#[derive(ModelBase)] declares shared columns once (id, created_at, updated_at, ...); a model embeds them flat with a single #[model(base = ...)] — the Django abstract-base-model pattern, native fields and all.
Reusable model bases
Most tables in a real app repeat the same handful of columns — a primary key, created_at, updated_at. Declaring them on every model is the kind of duplication Django solved with an abstract base model. umbral does the same with #[derive(ModelBase)] plus a #[umbral(flatten)] field.
Declare the shared columns once, with the full #[umbral(...)] attribute set:
use umbral::prelude::*;use chrono::{DateTime, Utc}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow, ModelBase)]pub struct TimeStamped { #[umbral(primary_key)] pub id: i64, #[umbral(auto_now_add)] pub created_at: DateTime<Utc>, #[umbral(auto_now)] pub updated_at: DateTime<Utc>,}The flat way: #[model(base = ...)]
For a single base — the dominant case, one audit-timestamp mixin — reach for the #[model(base = ...)] attribute. One line embeds the base as real, flat fields: you access article.id and article.created_at natively, never article.base.id.
#[model(base = TimeStamped)]#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, sqlx::FromRow, Model)]#[umbral(table = "article")]pub struct Article { pub title: String, #[umbral(unique)] pub slug: String,}That's the whole declaration. Article gains id, created_at, updated_at as top-level fields spliced in ahead of title / slug, so:
let a = Article::objects().create(Article { title: "Hello".into(), slug: "hello".into(), ..Default::default() // fills the base's id/created_at/updated_at placeholders}).await?; assert!(a.id > 0); // native — no `.base.`assert!(a.created_at > epoch); // auto_now_add stamped it Article::objects() .filter(Article::CREATED_AT.gt(cutoff)) // typed const, no mixin_cols! line .order_by(Article::ID.desc());Write #[model(base = ...)] ABOVE the #[derive(...)] line. Attribute macros expand top-to-bottom, and this one has to splice the base's fields in before the derives read the struct — the same rule as serde_with::serde_as. Put it below the derives and you get a clear error telling you to flip the order.
What the single attribute buys you, versus the nested-field form below:
- Native field access —
article.id, notarticle.base.id. - One attribute, not four — no nested
basefield and no#[umbral(flatten)] #[serde(flatten)] #[sqlx(flatten)]trio. - Typed column consts for free —
Article::CREATED_ATworks immediately, with nomixin_cols!line (the inherited columns are the model's own fields, so#[derive(Model)]emits their consts like any other).
The DB schema and JSON shape are identical to the nested-field form — same columns, same flat JSON — so the two mechanisms coexist and you can move a model between them without a migration.
Several bases compose too. #[model(base = A, B, …)] splices multiple bases in as flat, native fields in declaration order — #[model(base = TimeStamped, SoftDelete)] gives you article.id, article.created_at and article.deleted_at, all top-level. At most one base may declare the primary key. See Composing several bases.
The nested-field way: #[umbral(flatten)]
The original form embeds a base as a nested field, and is still the way to compose several bases into one model. The three attributes on the field are what make it work — #[umbral(flatten)] splices the base's columns into the model, and #[serde(flatten)] + #[sqlx(flatten)] flatten its values on write and read:
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow, Model)]pub struct Article { #[umbral(flatten)] #[serde(flatten)] #[sqlx(flatten)] pub base: TimeStamped, pub title: String, #[umbral(unique)] pub slug: String,}Article now has four columns — id, created_at, updated_at, title, slug — exactly as if you had written them inline. The migration engine creates them, Article::objects().create(...) stamps created_at/updated_at and autoincrements the base's id, and reads hydrate the nested base:
let a = Article::objects().create(Article { base: Default::default(), // id: 0, created_at/updated_at: epoch — placeholders only title: "Hello".into(), slug: "hello".into(),}).await?; assert!(a.base.id > 0); // base PK autoincremented over the 0 sentinelassert!(a.base.created_at > epoch); // auto_now_add stamped a real timestamp#[derive(ModelBase)] gives the base a Default impl (and a new() alias) for free, so construction collapses to base: Default::default() — no more spelling out id: 0, created_at: by hand. This is safe because the typed insert (and update) path always overwrites the primary key and every auto_now_add / auto_now / auto_uuid column, regardless of what the struct carries — the Default values are placeholders that never reach the database. Don't also derive or hand-write Default on a #[derive(ModelBase)] struct; the generated impl will conflict with it.
The base carries the primary key. When a model has no id of its own and embeds exactly one base whose base declares the PK, that column becomes the model's primary key — no extra wiring. A model can still declare its own id and use a base purely for non-key columns.
A soft-delete base
Bases pair naturally with the framework's other model features. A common one is soft-delete: a base carries the deleted_at tombstone column, and any model that embeds it and marks itself #[umbral(soft_delete)] gets hide-instead-of-delete for free.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow, ModelBase)]pub struct SoftDeleteBase { #[umbral(primary_key)] pub id: i64, pub deleted_at: Option<DateTime<Utc>>,} #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow, Model)]#[umbral(soft_delete)] // ← the marker stays on the MODELpub struct Document { #[umbral(flatten)] #[serde(flatten)] #[sqlx(flatten)] pub base: SoftDeleteBase, pub title: String,}Document::objects().delete() now writes deleted_at = now() instead of removing the row; default queries auto-filter WHERE deleted_at IS NULL, and .with_deleted() opts back in:
Document::objects().filter(...).delete().await?; // soft: sets deleted_atDocument::objects().count().await?; // excludes tombstoned rowsDocument::objects().with_deleted().count().await?; // includes them#[umbral(soft_delete)] is a struct-level marker, so it stays on the embedding model — a base can't carry it. What the base does carry is the deleted_at column the feature needs. This is the general pattern: put the shared columns on the base; keep model-level markers (soft_delete, audited, table = "...") on the model. See Soft delete for the full behavior.
Composing several bases
The flat way — #[model(base = A, B, …)]. List the bases; their columns splice in as flat, native fields in declaration order (A's, then B's, then the model's own):
#[model(base = TimeStamped, SoftDelete)]#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, sqlx::FromRow, Model)]#[umbral(table = "article")]pub struct Article { pub title: String,}// article.id, article.created_at, article.updated_at (TimeStamped),// article.deleted_at (SoftDelete), article.title — all top-level.At most one base may declare the primary key; a base contributing only non-key columns (a bare deleted_at) simply omits #[umbral(primary_key)]. The typed consts (Article::CREATED_AT, Article::DELETED_AT) are generated for every base's columns automatically — no mixin_cols! needed.
The nested-field way — #[umbral(flatten)]. The older form embeds each base as a nested field (accessed as comment.audit.id, not comment.id); still supported:
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow, Model)]#[umbral(soft_delete)]pub struct Comment { #[umbral(flatten)] #[serde(flatten)] #[sqlx(flatten)] pub audit: TimeStamped, // id, created_at, updated_at #[umbral(flatten)] #[serde(flatten)] #[sqlx(flatten)] pub trash: Deletable, // deleted_at (a base without its own PK) pub body: String,}Every attribute works on the base
A base field supports the exact same #[umbral(...)] attributes as a model field — primary_key, auto_now_add, auto_now, unique, index, default, max_length, choices, FK options, and so on. They are resolved on the base's own derive, so the column behaves identically whether it was declared inline or inherited.
Typed column constants for base fields
This section applies to the nested-field #[umbral(flatten)] form only. With #[model(base = ...)] the inherited columns are already the model's own fields, so Article::CREATED_AT and friends are generated automatically — no mixin_cols! line needed.
A model's own fields get typed constants (article::TITLE, Article::TITLE) for building filter / order_by predicates. With the nested-field form, base-inherited columns need one opt-in line — mixin_cols! — to get the same, bound to your model:
umbral::mixin_cols!(Article: TimeStamped); // one line, after the struct Article::objects() .filter(Article::ID.ge(100)) .order_by(Article::CREATED_AT.desc()) // now typed, like an own fieldmixin_cols!(Model: Base) generates Model::ID, Model::CREATED_AT, … for every column the base contributes. Pass several bases (mixin_cols!(Comment: TimeStamped, SoftDeleteBase)) to pull consts from each.
Same-crate vs. cross-crate. When the base lives in the same crate as the model (the common case), name it as you normally would — mixin_cols!(Article: TimeStamped). When the base comes from another crate, spell that crate so the generated-const macro resolves: mixin_cols!(Article: shared::TimeStamped). Without mixin_cols! the base columns still fully work for migrations, inserts, auto-stamping and reads — you only lose the typed query consts.
See also
- Auto timestamps and the
auto_now_add/auto_nowattributes for the stamping behavior a base typically bundles. arch.mdandcrates/umbral-core/src/orm/model.rs(ModelBase) for the design.