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 type | SqlType | Postgres column | SQLite column |
|---|---|---|---|
i8 · i16 · u8 | SmallInt | SMALLINT | SMALLINT |
i32 · u16 | Integer | INTEGER | INTEGER |
i64 · u32 | BigInt | BIGINT | BIGINT |
f32 | Real | FLOAT | REAL |
f64 | Double | DOUBLE PRECISION | DOUBLE |
bool | Boolean | BOOLEAN | BOOLEAN (0/1) |
String | Text | TEXT | TEXT |
chrono::NaiveDate | Date | DATE | TEXT (ISO 8601) |
chrono::NaiveTime | Time | TIME | TEXT (ISO 8601) |
chrono::DateTime<Utc> | Timestamptz | TIMESTAMP WITH TIME ZONE | TEXT (ISO 8601) |
uuid::Uuid | Uuid | UUID | TEXT (36-char canonical) |
serde_json::Value | Json | JSONB | TEXT (JSON-as-string) |
Vec<u8> | Bytes | BYTEA | BLOB |
Vec<T> (Postgres-only, T ≠ u8) | Array(T) | T[] | - (boot fails) |
ipnetwork::IpNetwork (Postgres-only) | Inet | INET | - (boot fails) |
ipnetwork::IpNetwork + #[umbral(cidr)] (Postgres-only) | Cidr | CIDR | - (boot fails) |
mac_address::MacAddress (Postgres-only) | MacAddr | MACADDR | - (boot fails) |
mac_address::MacAddress + #[umbral(macaddr)] (Postgres-only) | MacAddr | MACADDR | - (boot fails) |
String + #[umbral(xml)] (Postgres-only) | Xml | XML | - (boot fails) |
String + #[umbral(ltree)] (Postgres-only) | Ltree | LTREE | - (boot fails) |
String + #[umbral(bit)] (Postgres-only) | Bit | BIT VARYING | - (boot fails) |
umbral::orm::TsVector (Postgres-only) | FullText | TSVECTOR | - (boot fails) |
rust_decimal::Decimal (Postgres-only) | Decimal | NUMERIC(19, 4) | - (boot fails) |
Option<rust_decimal::Decimal> (Postgres-only) | Decimal (nullable) | NUMERIC(19, 4) | - (boot fails) |
ForeignKey<T> | ForeignKey | BIGINT 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:
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>>,}| Layer | What happens |
|---|---|
| Postgres DDL | column emits as VARCHAR(64), so PG enforces the cap with the same error path it uses for any constraint |
| SQLite DDL | column 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 widget | Text + max_length > 0 renders as <input type="text" maxlength="64"> (single-line). Without max_length, the field renders as a <textarea> (prose). |
| Admin changelist | The 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:
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 want | Reach 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 |
| Both | Set 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 type | Notes |
|---|---|
i64 | The default for new models. BIGINT + BIGSERIAL (Postgres) / INTEGER PRIMARY KEY AUTOINCREMENT (SQLite). |
i32 | When you genuinely don't need 64 bits. |
uuid::Uuid | The 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:
| Attribute | Effect |
|---|---|
#[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. |
// ← 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:
#[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
| Attribute | Effect |
|---|---|
#[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. |
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:
#[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:
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
| Attribute | What 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.
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:
| Attribute | What it does |
|---|---|
min_length = N | Reject values shorter than N characters. |
max_length = N | Reject values longer than N characters. |
email | Use the email validator (@, basic shape). Renders as <input type="email"> in the form helper. |
password | Use the password validator. Renders as <input type="password">. Combine with min_length for strength. |
optional | Skip Required. Implicitly on for Option<T> fields. |
Rust-type dispatch is automatic:
| Field type | Validator |
|---|---|
String | Field::text (or email/password per attribute) |
i8 / i16 / i32 / i64 / u8 / u16 / u32 / u64 | Field::integer |
f32 / f64 | Field::integer (numeric, accepts decimals) |
bool | Field::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.
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:
// 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:
| Mechanism | Layer | Who reads it |
|---|---|---|
#[umbral(default = "draft")] on the field | Schema. 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 enum | Rust. 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. |
#[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
| Item | Why |
|---|---|
impl umbral::orm::ChoiceField | Trait 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 FromStr | String round-trip. |
impl sqlx::Type + Encode + Decode for Sqlite and Postgres | The bind / decode path. sqlx::query! works without per-variant glue. |
#[choices(...)] modifiers
| Where | Attribute | Effect |
|---|---|---|
| Enum | rename_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). |
| Variant | value = "..." | Override the DB string for one variant. Bypasses rename_all. |
| Variant | label = "..." | Override the admin-form label for one variant. Defaults to the Rust variant ident verbatim. |
Validation, layered
| Layer | Where it fires | What it catches |
|---|---|---|
| The Rust type system | Compile time | Post { status: PostStatus::Bogus } doesn't exist; the variant has to come from the enum's closed set. |
sqlx Decode | SELECT time | A 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 constraint | INSERT / UPDATE time | A 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 aChoicesenum isn't accepted by the derive yet. For a nullable choice, add aNonevariant to the enum and use the non-Option field. (Tracked.)- Adding a new variant to the enum doesn't auto-rewrite the existing Postgres
CHECKconstraint - re-runningmakeafter 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.
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
| Concern | Choices (single) | MultiChoice<E> (multi) |
|---|---|---|
| Rust field type | EnumName directly | MultiChoice<EnumName> wrapper |
| Attribute marker | #[umbral(choices)] required | Detected from the type - no marker |
| DB storage | TEXT, one variant string | TEXT, CSV of variant strings |
Postgres CHECK | CHECK (col IN ('a', 'b', ...)) | Skipped at v1 - application-layer enforcement via sqlx Decode |
| Admin widget | <select> | Checkbox-chip group + hidden CSV |
| Order preserved? | n/a | Yes - 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
CHECKconstraint emitted on Postgres - validating "every CSV piece is a known variant" needs a regex with per-variant escaping. Application-layer enforcement via sqlx'sDecodepath is the v1 stance; a defence-in-depthCHECKlands when a real consumer needs it. - Filtering on a
MultiChoicecolumn 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.
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_fromleaves it alone.
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 update | Result |
|---|---|
new title, no slug | slug re-derives from the new title |
an explicit slug | your 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:
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:
| Type | Why it's portable |
|---|---|
String → TEXT | Same on both. |
chrono::* | sqlx encodes / decodes ISO 8601 on SQLite, native types on Postgres. |
uuid::Uuid | Native on Postgres, 36-char canonical text on SQLite. The Rust type sees Uuid either way. |
bool | Native on Postgres; 0 / 1 storage on SQLite with sqlx encoding both as bool. |
serde_json::Value | JSONB 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.
#[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().
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
SqlitePoolwith an error pointing at the model and field name. - Forces the model's QuerySet to use the
_pgterminal variants (fetch_pg(&pool),first_pg(&pool), etc.) - the cross-backendfetch()/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:
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:
LTREErequires theltreeextension in the target database (CREATE EXTENSION ltree). umbral emits the bareltreecolumn type; installing the extension is the operator's job.bitrenders as variable-lengthBIT VARYING. A fixed-widthBIT(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 amac_address::MacAddressfield. The type already auto-detects toMACADDR, so the attribute only documents intent.inspectdbrecovers these columns as plainStringfields (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.
#[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:
#[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):
#[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
- If
noformis set, the field is skipped on all forms.noeditis not checked. - If
noeditis set (andnoformis not), the field is shown read-only on edit and hidden on create. - If
AdminModel::readonly_fields(&["..."])also lists the column, it is always read-only regardless ofnoedit.
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:
#[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,}{# 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.
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):
#[umbral(widget = "rte", help = "Rich text.")]pub announcement: String, // stores 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.
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:
#[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.