This version is in beta. Some features may change before release.

Column types

Every Rust field type the Model derive recognises, what it maps to on Postgres and SQLite, and how to handle column constraints (length, default, unique, index).

#[derive(Model)] reads each field's Rust type and emits one column in the migration. The catalogue is closed: every supported type is in the table below. Off-catalogue types fail at derive time with a compile-time error that points at the field and names the issue.

Quick lookup

The cross-backend types compile and run against both Postgres and SQLite. The Postgres-only types fail the boot-time system check on SQLite.

Rust field typeSqlTypePostgres columnSQLite column
i8 · i16 · u8SmallIntSMALLINTSMALLINT
i32 · u16IntegerINTEGERINTEGER
i64 · u32BigIntBIGINTBIGINT
f32RealFLOATREAL
f64DoubleDOUBLE PRECISIONDOUBLE
boolBooleanBOOLEANBOOLEAN (0/1)
StringTextTEXTTEXT
chrono::NaiveDateDateDATETEXT (ISO 8601)
chrono::NaiveTimeTimeTIMETEXT (ISO 8601)
chrono::DateTime<Utc>TimestamptzTIMESTAMP WITH TIME ZONETEXT (ISO 8601)
uuid::UuidUuidUUIDTEXT (36-char canonical)
serde_json::ValueJsonJSONBTEXT (JSON-as-string)
Vec<u8>BytesBYTEABLOB
Vec<T> (Postgres-only, Tu8)Array(T)T[]- (boot fails)
ipnetwork::IpNetwork (Postgres-only)InetINET- (boot fails)
ipnetwork::IpNetwork + #[umbral(cidr)] (Postgres-only)CidrCIDR- (boot fails)
mac_address::MacAddress (Postgres-only)MacAddrMACADDR- (boot fails)
mac_address::MacAddress + #[umbral(macaddr)] (Postgres-only)MacAddrMACADDR- (boot fails)
String + #[umbral(xml)] (Postgres-only)XmlXML- (boot fails)
String + #[umbral(ltree)] (Postgres-only)LtreeLTREE- (boot fails)
String + #[umbral(bit)] (Postgres-only)BitBIT VARYING- (boot fails)
umbral::orm::TsVector (Postgres-only)FullTextTSVECTOR- (boot fails)
rust_decimal::Decimal (Postgres-only)DecimalNUMERIC(19, 4)- (boot fails)
Option<rust_decimal::Decimal> (Postgres-only)Decimal (nullable)NUMERIC(19, 4)- (boot fails)
ForeignKey<T>ForeignKeyBIGINT REFERENCES "t"("id")BIGINT REFERENCES "t"("id")

Any of these types wrapped in Option<T> produces the nullable column. Option is the only path to a nullable column - there is no null = true attribute.

Off-catalogue types and the explicitly-rejected wide / unsigned ints (i128, u64, u128) fail at derive time with a clear message pointing at the field's span.

"How do I write VARCHAR(64)?"

Two layers, two knobs. Pick the one whose error site matches where you want the failure to surface.

At the model - #[umbral(max_length = N)]

Declared on a String field in #[derive(Model)]. Three effects, all opt-in:

Code
rust
use umbral::prelude::*;
 
#[derive(Debug, Clone, sqlx::FromRow, Model)]
pub struct Article {
pub id: i64,
#[umbral(string, max_length = 64)]
pub title: String,
pub body: String, // unbounded TEXT
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
}
LayerWhat happens
Postgres DDLcolumn emits as VARCHAR(64), so PG enforces the cap with the same error path it uses for any constraint
SQLite DDLcolumn stays TEXT. SQLite's type system is affinity-based; VARCHAR(64) and TEXT carry the same affinity and SQLite never enforces declared length. Nothing changes for you on SQLite.
Admin form widgetText + max_length > 0 renders as <input type="text" maxlength="64"> (single-line). Without max_length, the field renders as a <textarea> (prose).
Admin changelistThe value is truncated at N characters when rendered in list_display, with appended so the cut is visible.

#[umbral(string)] is the sibling attribute - it marks one field as the model's human-readable label, so the admin's default list_display becomes [<that field>, <other short columns>] when no explicit config is registered. See Models - Display hints for the full opt-in story.

At the boundary - #[derive(Form)]

Use this when you want the validation to fire on the request boundary (a 400 with field-level errors), not at the database. The validator is a separate struct that deserialises the request body:

Code
rust
use umbral::prelude::*;
 
#[derive(serde::Deserialize, Form)]
pub struct CreatePost {
#[form(max_length = 64)]
title: String,
#[form(min_length = 10, max_length = 5000)]
body: String,
}

The Form derive produces a typed validator that returns 400 Bad Request with { field: "title", error: "max_length" } when the body doesn't satisfy the constraints. Use it for caps that vary between contexts (a username field that allows 64 chars at signup but 32 in the admin's profile edit) or for validation primitives the model layer doesn't carry (min_length, email, password strength).

Picking between them

You wantReach for
One length cap, enforced everywhere#[umbral(max_length = N)] on the model
Different caps in different request flows#[derive(Form)] per request
Database-level enforcement on Postgres#[umbral(max_length = N)] (lifts to VARCHAR(N))
Min length, email shape, password strength#[derive(Form)] - model attributes don't cover these
BothSet both. They compose - #[umbral(...)] shapes the DDL + admin; #[derive(Form)] validates inputs ahead of the model.

If you need a database-level constraint the model attributes don't cover (regex, unique-conditional-on-something, computed values), edit the generated migration JSON directly. The model snapshot doesn't round-trip those, so the constraint lives in migrations only - which is fine, because at that point the DB schema is the source of truth for that rule, not the Rust struct.

Picking the primary key

The id field is the primary key. Three types are supported:

Rust typeNotes
i64The default for new models. BIGINT + BIGSERIAL (Postgres) / INTEGER PRIMARY KEY AUTOINCREMENT (SQLite).
i32When you genuinely don't need 64 bits.
uuid::UuidThe framework doesn't auto-generate. Pass Uuid::new_v4() (or v7) on Manager::create(...).

Autoincrement happens for i32 / i64 PKs only. Pass id: 0 to bulk_create / create to let the DB assign; pass a non-zero value to force a specific ID. For Uuid, the framework expects you to generate the value: pass Uuid::new_v4() (or v7) at create time. The auto-generation sentinel (Uuid::nil() triggers DB-side default) only fires if you've manually added DEFAULT gen_random_uuid() to the migration; the derive doesn't emit a default at v1.

Custom PK names (pub user_id: i64 instead of pub id: i64) aren't supported at v1 - the derive looks for the literal field name id. Workaround: rename the field to id and add a getter, or write the Model impl by hand. Tracked as a deferred follow-on; tell us if you hit this.

Attributes the derive accepts today

Every attribute lives in one of two places - either on the struct itself (model-wide settings) or on an individual field inside the struct (column-specific settings). The examples below show both placements explicitly.

Struct-level - placed on #[derive(Model)] above the struct

These four attributes configure the model as a whole. They go on the struct, not on any field inside it:

AttributeEffect
#[umbral(table = "...")]Override the snake_case-of-struct-name table default.
#[umbral(plugin = "...")]Prefix the table with the plugin namespace (blog_post). Skipped silently when plugin = "app" since that's the implicit user-binary namespace.
#[umbral(display = "...")]Human label shown in the admin sidebar. Defaults to the struct name.
#[umbral(icon = "...")]Lucide icon slug. Defaults to database.
Code
rust
// ← Struct-level attributes go HERE, above the struct keyword
#[derive(Model)]
#[umbral(table = "auth_user", display = "Users", icon = "users")]
pub struct User {
pub id: i64,
pub email: String,
}

Field-level - placed on each field inside the struct

Every attribute below is declared on the field line itself, right before the field's name and type. They control how that single column behaves in the database, the admin, and the REST API:

Code
rust
#[derive(Model)]
pub struct Post {
pub id: i64,
// ↓ Field-level attribute on this specific column
#[umbral(string, max_length = 64)]
pub title: String,
}

The macro reads each field-level attribute straight off the struct field. Empty cells (-) mean the attribute has no effect at that layer; cells with a mean the framework wires the corresponding behaviour automatically.

Field-level reference table

AttributeEffect
#[umbral(auto_now_add)]Stamp Utc::now() on INSERT. The required-field check skips the column; clients can omit it from the body and the ORM fills it in at the write seam. Standard created_at pattern.
#[umbral(auto_now)]Stamp Utc::now() on INSERT and every UPDATE. auto_now_add columns stay frozen at their creation timestamp; auto_now columns refresh continuously. Standard updated_at pattern.
#[umbral(noform)]Client never writes - stripped from insert_json / update_json bodies. The admin hides the column on every form. Use for password_hash, internal tokens, server-managed audit fields.
#[umbral(noedit)]Admin edit form shows the field read-only (UX hint, not an API contract). REST writes still accept the value.
#[umbral(index)]Single-column index in the DDL. The migration engine emits CREATE INDEX on both backends.
#[umbral(unique)]UNIQUE constraint in the DDL. UNIQUE violations on insert / update surface as WriteError::UniqueViolation { field, value } - REST renders them as { "slug": ["A row with slug='widget' already exists."] }.
#[umbral(default = "...")]DDL default for the column - the string lands verbatim in the DEFAULT clause (booleans are translated to 1/0 on SQLite). For a choices field use the DB literal ("draft"), not the Rust path ("PostStatus::Draft"); see Defaults: two mechanisms. Required when adding a NOT NULL column to a populated table - Postgres / SQLite reject the ALTER otherwise. Also the value the DB uses when an INSERT omits the column.
#[umbral(help = "...")]OpenAPI description + a hint line under the admin form input. One place to say "use Markdown here" or "ISO-8601 date": it reaches both the API docs and the editor.
#[umbral(widget = "markdown" \| "rte" \| "code" \| "textarea")]Presentation hint for form renderers (features.md #4). Metadata only, no DDL change. In the admin: markdown mounts an EasyMDE editor (toolbar + sandboxed live preview), rte a Quill rich-text editor (stores HTML, render with \| sanitize), code a CodeMirror editor (JSON syntax + line numbers) for JSON/structured text. Pair markdown with the \| markdown filter on the display side. An unrecognised widget name falls back to the type-derived input, so plugin-defined widgets never break the form.
#[umbral(example = "...")]OpenAPI example value. Generated clients show it as a placeholder; the playground uses it to seed the request body.
#[umbral(on_delete = "cascade" \| "restrict" \| "set_null" \| "no_action")]FK ON DELETE clause. Default no_action (SQL standard's default - no extra clause emitted). Applies only to ForeignKey<T> columns; a typed error fires at derive time if you set it on a non-FK field.
#[umbral(on_update = "...")]FK ON UPDATE clause. Same vocabulary as on_delete.
#[umbral(min = N)] / #[umbral(max = N)]Numeric range validator. The dynamic-write path returns WriteError::Validator { field, message }; Postgres also gets a DDL CHECK so direct DB writes are caught. SQLite skips the CHECK (its CHECK syntax is more friction than safety here).
#[umbral(backend = "postgres")] / #[umbral(backend = "sqlite")]Restrict this field to a specific backend. The boot-time system check fails when the active backend isn't in the allow-list. Repeat the attribute to allow several.
#[umbral(string)] / #[umbral(string = true)]Mark this field as the model's human-readable label (display field). The admin uses it as the label column in the default list_display and as the picker label in FK / M2M choosers. See Models - Display hints.
#[umbral(max_length = N)]Cap on a String field. Postgres DDL emits VARCHAR(N); SQLite stays TEXT (affinity-equivalent). Admin form renders as <input maxlength="N"> instead of <textarea>; changelist truncates display at N chars.
#[umbral(choices)]The field's type implements [umbral::orm::ChoiceField] (typically via #[derive(Choices)]). Stored as TEXT; admin renders a <select>; Postgres gets a CHECK constraint. See "Using a Rust enum" below.
#[umbral(cidr)]On an ipnetwork::IpNetwork field, declare it as Postgres CIDR (network address) instead of the default INET. Postgres-only.
#[umbral(macaddr)]Explicit marker for a mac_address::MacAddress field (MACADDR). The type already auto-detects, so this only documents intent. Postgres-only.
#[umbral(xml)] / #[umbral(ltree)] / #[umbral(bit)]On a String (or Option<String>) field, declare it as a Postgres XML / LTREE / BIT VARYING column instead of TEXT. Text-backed - the Rust type stays String. Postgres-only. See Text-backed Postgres types.
Code
rust
use chrono::{DateTime, Utc};
use umbral::prelude::*;
 
#[derive(Debug, Clone, sqlx::FromRow, Model)]
pub struct Post {
pub id: i64,
#[umbral(string, max_length = 64)] // label + VARCHAR(64) on PG
pub title: String,
pub body: String, // unbounded TEXT
#[umbral(unique, max_length = 80)] // UNIQUE constraint
pub slug: String,
#[umbral(index)] // single-column index
pub status: String,
#[umbral(min = 0, max = 100_000)] // bounded integer
pub view_count: i64,
 
/// Set once at INSERT time, never updated.
#[umbral(auto_now_add)]
pub created_at: DateTime<Utc>,
 
/// Refreshed on every UPDATE.
#[umbral(auto_now)]
pub updated_at: DateTime<Utc>,
 
#[umbral(noform)] // never appears on any form
pub internal_token: String,
}

Validation that's not on the model

Some constraints belong at the request boundary instead of the schema - min_length, email shape, password strength, multi-field invariants. Use #[derive(Form)] for those:

Code
rust
#[derive(serde::Deserialize, Form)]
pub struct CreateUser {
#[form(min_length = 3, max_length = 32)]
username: String,
#[form(email)]
email: String,
#[form(password, min_length = 12)]
password: String,
}

Form and the model derive compose - the model shapes the DDL and the admin; Form validates inputs ahead of the model. See #[derive(Form)] below.

Foreign derive attributes (#[serde(...)], #[sqlx(...)], #[validate(...)], etc.) are passed through untouched - the derive doesn't reject them, which lets you stack umbral's macro on top of whatever serialization / validation stack you already use.

All derive macros at a glance

umbral ships four proc-macros. The model derive is the most-used; the choices derive turns a Rust enum into a column field type; the form derive validates request bodies; the task attribute marks background jobs. Each gets a worked example below.

#[derive(Model)] - the schema + admin surface

Defined in crates/umbral-macros/src/lib.rs::derive_model. Reads each struct field, emits an impl Model, the objects() Manager entry point, and a sibling column module (article::ID, article::TITLE, …).

Every attribute it accepts:

Code
rust
use umbral::prelude::*;
 
#[derive(Debug, Clone, sqlx::FromRow, Model)]
#[umbral(
table = "blog_post", // override the snake_case-of-struct-name default
plugin = "blog", // prefix the table (no-op when plugin = "app")
display = "Blog post", // human label shown in the admin sidebar
icon = "newspaper", // Lucide icon slug (defaults to `database`)
)]
pub struct Post {
pub id: i64,
 
#[umbral(string, max_length = 80)] // __str__ label + VARCHAR(80) on PG
pub title: String,
 
pub body: String, // unbounded TEXT, renders as <textarea>
 
#[umbral(noedit)] // read-only on edit, hidden on create
pub created_at: chrono::DateTime<chrono::Utc>,
}

Model Level

AttributeWhat it does
table = "..."SQL table name. Default: snake_case of struct ident.
plugin = "..."Table prefix (blog_post). plugin = "app" is a no-op since "app" is the implicit user-binary namespace.
display = "..."Sidebar label in the admin. Default: struct name.
icon = "..."Lucide icon slug. Default: "database".

#[derive(Form)] - request-boundary validation

Defined in crates/umbral-macros/src/lib.rs::derive_form. Implements umbral::forms::Form so the struct can be .parse(body)-ed against a urlencoded or JSON body and reports field-level errors. Use it on a separate struct from your model - one for create, one for update, one per request shape that varies.

Code
rust
use umbral::prelude::*;
 
#[derive(serde::Deserialize, Form)]
pub struct CreateUser {
#[form(min_length = 3, max_length = 32)]
username: String,
 
#[form(email)]
email: String,
 
#[form(password, min_length = 12)]
password: String,
 
#[form(optional, max_length = 200)]
bio: Option<String>,
}

Reference:

AttributeWhat it does
min_length = NReject values shorter than N characters.
max_length = NReject values longer than N characters.
emailUse the email validator (@, basic shape). Renders as <input type="email"> in the form helper.
passwordUse the password validator. Renders as <input type="password">. Combine with min_length for strength.
optionalSkip Required. Implicitly on for Option<T> fields.

Rust-type dispatch is automatic:

Field typeValidator
StringField::text (or email/password per attribute)
i8 / i16 / i32 / i64 / u8 / u16 / u32 / u64Field::integer
f32 / f64Field::integer (numeric, accepts decimals)
boolField::boolean
Option<T>inner type, marked .optional()

Off-catalogue field types (custom structs, Vec<T>, HashMap<K,V>) fail at derive time pointing at the field span.

#[task] - background jobs

The third macro is #[umbral::task], an attribute macro on async fn. It emits a companion register_<fn_name>() you call at boot. Full reference lives with the tasks plugin - see umbral-tasks › Declaring a task.

#[derive(Choices)] - using a Rust enum as a field type

A Rust enum can be used directly as a model field via #[umbral(choices)]. The framework stores the variant as TEXT in the database, lifts the variant list into the admin form (renders a <select>), and emits a Postgres CHECK (col IN (...)) constraint so a third-party process writing directly to the database can't insert a value the enum can't model.

Code
rust
use serde::{Deserialize, Serialize};
use umbral::prelude::*;
 
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Choices)]
#[choices(rename_all = "lowercase")]
pub enum PostStatus {
Draft,
Review,
Published,
Archived,
}
 
#[derive(Debug, Clone, sqlx::FromRow, Model)]
pub struct Post {
pub id: i64,
#[umbral(string, max_length = 80)]
pub title: String,
pub body: String,
#[umbral(choices, default = "draft")]
pub status: PostStatus,
}

That's the whole setup. Post { status: PostStatus::Draft, .. } round-trips through INSERT / SELECT as 'draft'. The admin's create / edit sheet shows a <select> with the four variants. The migration engine emits status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'review', 'published', 'archived')) on Postgres; SQLite gets plain TEXT DEFAULT 'draft' (SQLite's CHECK syntax differs and CHECK adds friction more than safety on a column the Rust enum already constrains).

The default = "..." is required when you add the status column to an existing populated table - both Postgres and SQLite reject ALTER TABLE ADD COLUMN for a NOT NULL column without a default. It also doubles as the value the DB uses on any future INSERT that omits the column.

Filtering by a choice value. The column constant filters on the stored string: the rename_all'd variant text, not the Rust path:

Code
rust
// All published posts. `"published"` is the DB literal for
// PostStatus::Published under `rename_all = "lowercase"`.
let live = Post::objects()
.filter(post::STATUS.eq("published"))
.fetch()
.await?;
 
// Membership across several states: OR the equalities with a Q object.
use umbral::orm::Q;
let active = Post::objects()
.filter(Q::or(post::STATUS.eq("review"), post::STATUS.eq("published")))
.fetch()
.await?;

Defaults: two mechanisms, two layers

A choices field can carry two different defaults, and they live at different layers:

MechanismLayerWho reads it
#[umbral(default = "draft")] on the fieldSchema. The string lands verbatim in the migration's DEFAULT clause.The database, whenever an INSERT omits the column.
#[default] on a variant + #[derive(Default)] on the enumRust. Standard-library attribute; the ORM never reads it.Anything that constructs the struct via Default - T::default() in a GET handler, the ..Default::default() fill the Form derive uses for noform fields.
Warning

#[umbral(default = "...")] takes the DB literal - the rename_all'd variant string ("draft"), never the Rust path. default = "PostStatus::Draft" emits DEFAULT 'PostStatus::Draft' into the DDL: on Postgres the CHECK constraint rejects it at insert time; on SQLite it stores text the enum can't decode, and the row errors on the next SELECT.

The two usually agree (#[default] Draft + default = "draft"), but they may differ deliberately: a public-submission form fills source from Default::default() (say community) while rows inserted without the column at the SQL level get the schema default (say official). Just be aware which path creates each row.

What #[derive(Choices)] emits

ItemWhy
impl umbral::orm::ChoiceFieldTrait carrying VALUES (DB strings) + LABELS (admin display) + as_str + from_str_ok. The Model derive reads VALUES / LABELS at compile time to populate the column's FieldSpec.
impl Display + impl FromStrString round-trip.
impl sqlx::Type + Encode + Decode for Sqlite and PostgresThe bind / decode path. sqlx::query! works without per-variant glue.

#[choices(...)] modifiers

WhereAttributeEffect
Enumrename_all = "..."Naming convention for the DB-stored variant strings. Accepts lowercase (default), UPPERCASE, snake_case, SCREAMING_SNAKE_CASE, kebab-case, none (preserve variant ident as-is).
Variantvalue = "..."Override the DB string for one variant. Bypasses rename_all.
Variantlabel = "..."Override the admin-form label for one variant. Defaults to the Rust variant ident verbatim.

Validation, layered

LayerWhere it firesWhat it catches
The Rust type systemCompile timePost { status: PostStatus::Bogus } doesn't exist; the variant has to come from the enum's closed set.
sqlx DecodeSELECT timeA row in the DB that holds a string not on VALUES returns a typed error from the query (instead of silently materialising a wrong variant).
Postgres CHECK constraintINSERT / UPDATE timeA direct DB write from an external process can't insert a string outside the variant list.

This composes with #[derive(Form)] if you have additional request-shape rules beyond the variant list (e.g. published only allowed when the row is noform-immutable elsewhere): use the Form derive at the boundary, the Choices-typed field on the model.

Limits at v1

  • Option<T> of a Choices enum isn't accepted by the derive yet. For a nullable choice, add a None variant to the enum and use the non-Option field. (Tracked.)
  • Adding a new variant to the enum doesn't auto-rewrite the existing Postgres CHECK constraint - re-running make after adding a variant generates a migration that drops + re-adds the constraint, which is fine but requires the migration step.
  • Removing a variant is a data migration the user owns: existing rows that hold the dropped variant become invalid; clean up the data first, then drop the variant.

MultiChoice<E> - multi-valued choices

MultiChoice<E> is the multi-valued counterpart to #[derive(Choices)]. Where a Choices field carries one variant, a MultiChoice<E> field carries an ordered list of distinct variants of the same enum. Storage is a single TEXT column holding a comma-separated list of the variants' DB strings - e.g. "design,frontend". The admin renders the field as a checkbox-chip group (one chip per variant) backed by a hidden CSV input.

Code
rust
use umbral::prelude::*;
 
#[derive(Debug, Clone, Copy, PartialEq, Eq, Choices)]
#[choices(rename_all = "lowercase")]
pub enum Tag { Design, Frontend, Backend, DevOps }
 
#[derive(Debug, Clone, sqlx::FromRow, Model)]
pub struct Article {
pub id: i64,
#[umbral(string, max_length = 120)]
pub title: String,
pub body: String,
#[umbral(default = "design,frontend")]
pub tags: MultiChoice<Tag>,
}

The Rust type drives detection - no #[umbral(multichoice)] marker is required; the macro recognises MultiChoice<E> directly. #[umbral(default = "...")] is a CSV of the initial variants, used both as the SQL DEFAULT and as the value ALTER TABLE ADD COLUMN applies to existing rows.

Reading and writing. Article { tags: vec![Tag::Design, Tag::Backend].into(), .. } round-trips through sqlx as the TEXT value 'design,backend'. The wrapper exposes the usual collection surface - .as_slice(), .into_vec(), .push(...), .contains(...), iteration, Deref<Target=[E]>. On the serde side it serialises to a JSON array of strings (["design","backend"]); deserialisation accepts both the JSON array form and the CSV string form (the latter is what HTML form posts produce).

What's different from Choices

ConcernChoices (single)MultiChoice<E> (multi)
Rust field typeEnumName directlyMultiChoice<EnumName> wrapper
Attribute marker#[umbral(choices)] requiredDetected from the type - no marker
DB storageTEXT, one variant stringTEXT, CSV of variant strings
Postgres CHECKCHECK (col IN ('a', 'b', ...))Skipped at v1 - application-layer enforcement via sqlx Decode
Admin widget<select>Checkbox-chip group + hidden CSV
Order preserved?n/aYes - declaration order in the Vec<E> round-trips

Limits at v1

  • No Option<MultiChoice<E>> - at v1 the field is non-nullable. Use an empty selection (MultiChoice::new()) instead of NULL.
  • No CHECK constraint emitted on Postgres - validating "every CSV piece is a known variant" needs a regex with per-variant escaping. Application-layer enforcement via sqlx's Decode path is the v1 stance; a defence-in-depth CHECK lands when a real consumer needs it.
  • Filtering on a MultiChoice column uses string predicates (MyModel::TAGS.contains("design")). No element-level membership filter at v1.

Slug, email & URL fields

umbral::orm::Slug, Email, and Url are validated text types: each stores as a TEXT column but carries a text_format marker (slug / email / url) that flows to OpenAPI (format / pattern), the REST write path (a structured 400 on a bad value), and the admin widget. The type alone gives you the format check - you still supply the value.

Code
rust
use umbral::orm::Slug;
 
#[derive(Debug, Clone, sqlx::FromRow, Model)]
#[umbral(table = "articles")]
pub struct Article {
pub id: i64,
pub title: String,
#[umbral(slug_from = "title", unique)]
pub slug: Slug,
}

Auto-deriving a slug

Slug only validates - it doesn't generate. Add #[umbral(slug_from = "<column>")] to fill the slug from a sibling column. slugify lowercases the source, replaces runs of non-alphanumerics with a single -, and trims dashes, so "Hello, World!" becomes "hello-world".

  • On create - an empty or absent slug becomes slugify(<source>).
  • On update - the slug regenerates from the source only when the source column is in the payload. If you don't send the source, the existing slug is left untouched, so editing an unrelated field never clobbers a hand-tuned slug.
  • An explicit, non-empty slug always wins - send your own value (on create or update) and slug_from leaves it alone.
Warning

slug_from runs on the dynamic write path - REST endpoints and the admin (anything going through JSON create / update). The typed path Article::objects().create(obj) does not auto-fill; build the value yourself with umbral::orm::slugify(&title).

Editing a title, keeping the slug in sync

The two update rules combine into a predictable editing model:

You send on updateResult
new title, no slugslug re-derives from the new title
an explicit slugyour slug is kept (frozen)
neither (only other fields)slug unchanged

Uniqueness on update

A #[umbral(unique)] slug is enforced by the database constraint - umbral runs no application-level pre-check. The dynamic update is UPDATE … WHERE <pk> = id, so re-sending a row its own slug never conflicts (no other row holds that value); you do not need to strip the slug from an update payload. A violation fires only when the slug collides with a different row - for example, renaming a title so its slug matches an existing post's.

Nullability

Option<T> is the only path to a nullable column. The migration engine emits the right NULL / NOT NULL clause for each backend:

Code
rust
pub struct Post {
pub id: i64,
pub title: String, // NOT NULL
pub published_at: Option<DateTime<Utc>>, // NULL
}

A column that goes from T to Option<T> is a non-destructive migration on both backends (drops NOT NULL). The reverse (Option<T>T) requires a data step: either a default for the existing nulls or filtering them out before the migration runs. The autodetector picks up the change either way; it's the apply step that needs the data to be valid.

Cross-backend types

Every type in the Quick lookup above the divider works on both backends. The Rust struct compiles and runs unchanged against either backend; only the underlying storage differs:

TypeWhy it's portable
StringTEXTSame on both.
chrono::*sqlx encodes / decodes ISO 8601 on SQLite, native types on Postgres.
uuid::UuidNative on Postgres, 36-char canonical text on SQLite. The Rust type sees Uuid either way.
boolNative on Postgres; 0 / 1 storage on SQLite with sqlx encoding both as bool.
serde_json::ValueJSONB on Postgres, TEXT on SQLite. The JSON operator surface picks the dialect-correct rendering at query time.
Vec<u8>BLOB on SQLite, BYTEA on Postgres. Cross-backend by sqlx encoding; admin renders as a text input with hex / array wire shape.

Most models stay on the cross-backend types and run on either backend, with SQLite for tests / dev and Postgres for prod.

Vec<u8> - binary payloads

Use for anything that stores opaque bytes: file uploads, encrypted envelopes, hash digests, the cache backend's value column. Maps to BLOB on SQLite and BYTEA on Postgres via sea_query::ColumnType::Blob.

Code
rust
#[derive(Model)]
pub struct Attachment {
pub id: i64,
#[umbral(string, max_length = 200)]
pub filename: String,
pub mime_type: String,
/// Raw bytes. NULL allowed via `Option<Vec<u8>>`.
pub data: Vec<u8>,
pub thumbnail: Option<Vec<u8>>,
}

The derive emits BytesCol<Self> for Vec<u8> and NullableBytesCol<Self> for Option<Vec<u8>>. Operator surface is small (byte columns rarely appear in WHERE clauses): .eq(&bytes), .ne(&bytes), .is_null() / .is_not_null() (nullable only), .asc() / .desc().

Code
rust
let by_hash = Attachment::objects()
.filter(attachment::DATA.eq(&content_hash))
.first()
.await?;

JSON wire shape (REST API request bodies, admin form values, backup dumpdata output):

  • Array of u8 numbers: [222, 173, 190, 239] - the canonical form.
  • Hex string: "deadbeef" - lowercase, even length. Accepted on input as a convenience for human-readable test fixtures and admin form values.

#[derive(Model)] distinguishes Vec<u8> from Vec<i16> at the type level: Vec<u8> routes to SqlType::Bytes (cross-backend), every other Vec<T> routes to SqlType::Array(T) (Postgres-only).

Postgres-only types

Vec<T>, ipnetwork::IpNetwork, mac_address::MacAddress, umbral::orm::TsVector, rust_decimal::Decimal (a fixed-point NUMERIC(19, 4); both Decimal and Option<Decimal> are supported), and the text-backed types XML / LTREE / BIT VARYING are Postgres-only. A model that includes one of these fields:

  • Boots clean against a PgPool.
  • Fails the boot-time system check against a SqlitePool with an error pointing at the model and field name.
  • Forces the model's QuerySet to use the _pg terminal variants (fetch_pg(&pool), first_pg(&pool), etc.) - the cross-backend fetch() / first() can't satisfy the dual-backend FromRow bound.

The per-type predicate surface (array .contains / .overlaps, JSON .path_text / .has_key, INET .eq) lives on the Declaring models page next to each section.

Text-backed Postgres types - xml, ltree, bit

XML, LTREE, and BIT VARYING have a faithful textual representation, so they map to a Rust String at the value level but get their own native Postgres column type. Opt in with a field attribute on a String (or Option<String>) field:

Code
rust
use umbral::prelude::*;
 
#[derive(Debug, Clone, sqlx::FromRow, Model)]
pub struct Document {
pub id: i64,
#[umbral(xml)] pub body: String, // XML
#[umbral(ltree)] pub path: String, // LTREE (e.g. "Top.Science.Astronomy")
#[umbral(bit)] pub flags: String, // BIT VARYING (e.g. "101")
#[umbral(xml)] pub note: Option<String>, // nullable XML
}

Notes and limits at v1:

  • LTREE requires the ltree extension in the target database (CREATE EXTENSION ltree). umbral emits the bare ltree column type; installing the extension is the operator's job.
  • bit renders as variable-length BIT VARYING. A fixed-width BIT(n) needs a hand-written migration until a #[umbral(bit_len = N)] attribute lands for a real consumer.
  • #[umbral(macaddr)] is an explicit marker for a mac_address::MacAddress field. The type already auto-detects to MACADDR, so the attribute only documents intent.
  • inspectdb recovers these columns as plain String fields (the attribute lives only in the source model, not the database), so re-add #[umbral(xml)] / #[umbral(ltree)] / #[umbral(bit)] if you want the native type back on a re-migrate.

Still deferred

The remaining Postgres types need a custom Rust value type or an external crate binding, so they're not shipped yet: interval (a duration type), hstore (a HashMap), range types (a Range), money (locale-sensitive), composite / enum types, and PostGIS geometry / geography (a geo-types binding plus GiST indexes and ST_* predicates). If you need one, tell us which and the use case.

Foreign keys

ForeignKey<T> is a cross-backend column type that stores an i64 reference to the primary key of model T. The migration engine emits BIGINT REFERENCES "<table>"("id") for both Postgres and SQLite.

Code
rust
#[derive(Model)]
pub struct Post {
pub id: i64,
pub title: String,
pub author: ForeignKey<User>, // BIGINT NOT NULL REFERENCES "user"("id")
pub reviewer: Option<ForeignKey<User>>, // BIGINT REFERENCES "user"("id") (nullable)
}

At query time, post::AUTHOR.eq(42) filters by the raw FK integer. To load the referenced row, call .resolve(&pool) on the field value. Full details on the Relationships page.

Form-level field attributes

Two field-level attributes tell the admin how to render (or suppress) a column on create and edit forms. They are declared on the struct field alongside your normal Rust type, and take effect automatically in the admin UI.

#[umbral(noform)]

The column never appears on any form - neither create nor edit. Use this for fields the user should never touch directly:

Code
rust
#[derive(Debug, sqlx::FromRow, umbral::orm::Model)]
pub struct AuthUser {
pub id: i64,
pub username: String,
#[umbral(noform)] // the admin hides this column on all forms
pub password_hash: String,
}

When noform is set, noedit is moot (noform takes precedence). Sensitive columns like password_hash benefit from the password_field("password_hash") AdminModel builder instead, which adds a dedicated "Change password" dialog on edit forms.

#[umbral(noedit)]

The column appears on the edit form as a read-only display, but cannot be changed. The user sees the value without being able to modify it. On create forms the column is omitted entirely (it has no value yet):

Code
rust
#[derive(Debug, sqlx::FromRow, umbral::orm::Model)]
pub struct AuthUser {
pub id: i64,
pub username: String,
#[umbral(noedit)] // shown read-only on edit; hidden on create
pub email: String,
#[umbral(noform)] // never shown
pub password_hash: String,
}

The admin renders a (read-only) badge next to the label and disables the input element.

Order of precedence

  1. If noform is set, the field is skipped on all forms. noedit is not checked.
  2. If noedit is set (and noform is not), the field is shown read-only on edit and hidden on create.
  3. If AdminModel::readonly_fields(&["..."]) also lists the column, it is always read-only regardless of noedit.

See the admin reference for the password_field(...) builder that combines noform with a dedicated password-change flow.

Rendering markdown safely

Text/body fields (plugin descriptions, usage docs, reviews) are usually authored in Markdown. Capture the source in a plain String column, hint the editor with #[umbral(widget = "markdown")] + #[umbral(help = "...")], and render it on the display side with the built-in | markdown filter:

Code
rust
#[derive(Debug, Clone, sqlx::FromRow, Model)]
pub struct Plugin {
pub id: i64,
pub name: String,
#[umbral(widget = "markdown", help = "Markdown supported: headings, lists, tables, code.")]
pub body: String,
}
Code
html
{# detail template: render the stored Markdown to HTML #}
<article class="prose">{{ plugin.body | markdown }}</article>

The filter renders CommonMark + GitHub-flavored extensions (tables, strikethrough, task lists, footnotes), then sanitizes the output: <script>, inline event handlers (onerror=, onclick=), and javascript: URLs are stripped, so user-submitted Markdown can't smuggle XSS into a page. The result is a safe string, so template autoescaping leaves the generated tags intact. It's a plain template filter, reusable everywhere, in the admin and your own end-user templates alike; nothing is admin-specific.

In the admin, a markdown field mounts a full editor inline on the create / edit form: EasyMDE with a toolbar and live side-by-side preview. The editor is lazy-loaded (the library only downloads on a page that actually has a markdown field) and degrades to a plain, usable textarea if JavaScript is off. The stored value is Markdown either way.

Info
The column stays plain `TEXT`: `widget` is a rendering hint, not a data type. Syntax-highlighted code blocks in the rendered output are a tracked follow-up.

The rte widget: rich text to HTML

#[umbral(widget = "rte")] mounts a Quill rich-text editor instead. Unlike markdown, the rte field stores HTML, so render it with the | sanitize filter (Quill's HTML, cleaned to a safe allowlist, the display companion to | markdown):

Code
rust
#[umbral(widget = "rte", help = "Rich text.")]
pub announcement: String, // stores HTML
Code
html
<div class="prose">{{ row.announcement | sanitize }}</div>

Always render stored HTML through | sanitize (never | safe): a value written via the REST API bypasses the editor, so the page is only safe if the display path cleans it. Prefer markdown when you can: Markdown source is smaller, diffable, and portable; reach for rte when non-technical editors need a Word-like surface.

Warning
The admin editor previews are sandboxed (rendered HTML is run through DOMPurify before it reaches the preview pane), so previewing authored content can't execute script. That's defense-in-depth on top of the server-side filters; it does not replace them. Stored values must still be rendered through `| markdown` / `| sanitize` on every display path.

The code widget: JSON / structured text

#[umbral(widget = "code")] mounts a CodeMirror editor with JSON syntax highlighting and line numbers: the comfortable way to edit a Json column or any String holding structured text:

Code
rust
#[umbral(widget = "code", help = "JSON object, see the schema docs.")]
pub config: serde_json::Value,

It's opt-in: a plain Json column without the widget keeps the default textarea-plus-Format-button editor, which already validates on save. Add widget = "code" when you want the richer editing surface. No display filter is needed: JSON is data, not markup; render individual values from the parsed object as usual.

See also

  • Relationships - declaring foreign keys, .resolve(), column constants, what is deferred.
  • Declaring models - the per-type sections with operator surfaces and concrete examples.
  • PostgreSQL backend - the full Postgres column-type table and DDL shapes.
  • SQLite backend - the SQLite column-type table and what's missing vs Postgres.
  • Managed migrations - what the migration engine writes for each column and how to edit it when you need a CHECK / INDEX / FK the derive doesn't emit.
ormtypescolumnsschema