Relationships
Declare foreign keys with ForeignKey<T>, what DDL gets emitted, and how to load the referenced row at runtime.
A ForeignKey<T> field declares that one row in the current model references a row in model T. Umbral stores the referenced primary key as a BIGINT column and emits a REFERENCES "<table>"("id") constraint in the migration.
Migration note: the ergonomic .resolved() reader was removed from ForeignKey, OneToOne, M2M, and ReverseSet ahead of 1.0 (a breaking change). Read a related object through the awaited accessor instead: post.author().await? returns the row directly, or use .get_opt().await? when the relation may be absent (a nullable FK, a OneToOne that might have no row, a reverse-O2O). A to-many relation reads with post.tags().fetch().await?, returning Vec. Both accessor shapes are served from the select_related / prefetch_related (or join_related) cache with zero further queries once that cache is populated, and fall back to a fresh query otherwise.
Declaring a foreign key
use umbral::orm::{ForeignKey, Model}; #[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize, umbral::orm::Model)]pub struct User { pub id: i64, pub name: String,} #[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize, umbral::orm::Model)]pub struct Post { pub id: i64, pub title: String, pub author: ForeignKey<User>, // stores user.id as BIGINT pub reviewer: Option<ForeignKey<User>>, // nullable FK}The ForeignKey<T> type is in scope via use umbral::orm::ForeignKey or use umbral::prelude::*.
What the migration engine emits
makemigrations produces a CREATE TABLE that includes the REFERENCES clause.
SQLite:
CREATE TABLE "post" ( "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "title" text NOT NULL, "author" bigint NOT NULL REFERENCES "user"("id"), "reviewer" bigint REFERENCES "user"("id"))Postgres:
CREATE TABLE "post" ( "id" bigserial PRIMARY KEY, "title" text NOT NULL, "author" bigint NOT NULL REFERENCES "user"("id"), "reviewer" bigint REFERENCES "user"("id"))Referential actions: on_delete / on_update
By default the FK emits no ON DELETE / ON UPDATE clause, which means "NO ACTION": the database blocks a delete of the parent if any child row references it, and refuses to update the parent's primary key. Override per FK with the on_delete and on_update attributes:
#[derive(Model)]pub struct AuthToken { pub id: i64, // When the user is deleted, every token they hold goes with them. #[umbral(on_delete = "cascade")] pub user_id: ForeignKey<AuthUser>, // ...}The attribute accepts four values, mirroring the SQL standard:
| Attribute value | Emits | Meaning |
|---|---|---|
"no_action" (default) | no clause | refuse the delete/update if children exist |
"cascade" | ON DELETE CASCADE | delete/update children too |
"restrict" | ON DELETE RESTRICT | block immediately, no commit-time deferral |
"set_null" | ON DELETE SET NULL | null the child column (only valid on Option<ForeignKey<T>>) |
on_update accepts the same vocabulary; useful when the parent PK might move (rare in practice, since most apps use immutable PKs).
The attribute applies at CREATE TABLE time only. Changing the action on an already-created table needs a hand-written migration: SQLite can't ALTER FK actions in place, and the diff engine doesn't watch constraint flags yet.
Self-referential foreign keys
A model can hold a ForeignKey to itself, useful for category trees, threaded comments, replies, manager hierarchies, anything that nests. Write the type literally:
#[derive(Model)]pub struct Category { pub id: i64, pub name: String, // Each category optionally points at its parent. CASCADE prunes // the subtree when a root is deleted. #[umbral(on_delete = "cascade")] pub parent_id: Option<ForeignKey<Category>>,}No Self keyword (Rust doesn't allow Self in a field type), no string sentinel for "this model", just the struct's own name. Why it works: ForeignKey<T> stores T::PrimaryKey and Option<Box<T>> (boxed so the type stays finite-size), and <T as Model>::TABLE resolves at the same expansion step that emits impl Model for Category, so T = Category is satisfied within one derive pass.
The DDL is a normal inline REFERENCES clause: the table references itself within the same CREATE TABLE statement, which both SQLite and Postgres handle natively:
CREATE TABLE "category" ( "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "name" text NOT NULL, "parent_id" bigint REFERENCES "category"("id") ON DELETE CASCADE)Querying a self-FK is the same as any other FK: the column constants live in the model's sibling module and accept i64 comparisons. To walk the tree, fetch one level at a time (or write a recursive CTE by hand if depth matters):
// Direct children of category 1:let children = Category::objects() .filter(category::PARENT_ID.eq(1)) .fetch() .await?;One-to-one relationships
A one-to-one relationship is a foreign key with a UNIQUE constraint underneath. Umbral ships two equivalent spellings on the child (referencing) side. Pick whichever reads cleaner; both compile to the same column shape and emit the same back-link accessors.
Sugar spelling: OneToOne<T>
#[derive(Model)]pub struct Profile { pub id: i64, pub user: OneToOne<AuthUser>, // sugar pub bio: String, pub avatar: String,}The derive macro sees OneToOne<T> without #[sqlx(skip)] and rewrites it internally to #[umbral(unique)] pub user: ForeignKey<AuthUser>: same column DDL (bigint NOT NULL UNIQUE REFERENCES "auth_user"("id")), same select_related("user") behavior, same reverse-O2O accessor on the parent (auth_user.profile().get_opt().await?). The OneToOne<T> type carries the FK value at runtime: construct with OneToOne::new(id), read with .id(), and (after select_related) read the related row through the awaited accessor — profile.user().await? resolves to the T with zero further queries once populated — symmetric with ForeignKey<T>.
Longhand spelling: #[umbral(unique)] ForeignKey<T>
#[derive(Model)]pub struct Profile { pub id: i64, // Each user has at most one profile. #[umbral(unique, on_delete = "cascade")] pub user: ForeignKey<AuthUser>, pub bio: String, pub avatar: String,}The generated DDL:
CREATE TABLE "profile" ( "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "user" bigint NOT NULL UNIQUE REFERENCES "auth_user"("id") ON DELETE CASCADE, "bio" text NOT NULL, "avatar" text NOT NULL)Reading from the child side
The FK is a regular ForeignKey<AuthUser>, so select_related works the same as any other FK:
let profile = Profile::objects() .select_related("user") .get(profile::USER.eq(user.id)) .await?; let user: AuthUser = profile.user().await?; // zero further queries: select_related populated itReading from the parent side
The relationship lives entirely on the child (Profile): you declare the one-to-one field on Profile and never touch the User model. To read it back from the parent (e.g. user.profile.avatar), how you spell it depends on whether you can edit the parent type.
For AuthUser - and any parent you can't edit - use the method accessor and add nothing to it. AuthUser lives in umbral-auth; you don't fork it, and you don't need to. The derive on Profile generates user.profile() directly on AuthUser, a chainable accessor for the related row (read it as Option with .get_opt().await?, since a profile may not exist). See the Cross-crate back-link section just below. The OneToOne struct field described next is only for a parent type you own (e.g. a custom user model in your crate).
OneToOne<C> - when you own the parent type
If the parent model is in your crate, you can declare a OneToOne<C> field on it instead of using the method accessor. You get the same back-link plus serde-nesting (the child shows up as a nested object in REST + templates) and prefetch_related batch loading. No umbral attribute - the back-link is discovered at runtime by scanning the child's FIELDS for the unique FK pointing back:
#[derive(Model)]pub struct Account { // a model YOU define pub id: i64, pub email: String, // Reads back the `Profile` whose `#[umbral(unique)] user: ForeignKey<Account>` // points here. No `#[umbral(...)]` needed - the back-link is found at runtime. #[sqlx(skip)] pub profile: OneToOne<Profile>,}Load it explicitly with prefetch_related:
let account = Account::objects() .prefetch_related("profile") .get(account::ID.eq(1)) .await?; // account.profile().get_opt().await? is Some(Profile) with zero further// queries: prefetch_related populated it.if let Some(profile) = account.profile().get_opt().await? { println!("{}", profile.avatar);}Query budget: 1 (accounts) + 1 (profiles) regardless of how many come back, no N+1.
In a template the serialised account carries the profile as a nested object (or null), so {{ account.profile.avatar }} works directly:
serde_json::to_value(&account)["profile"]["avatar"] // "alice.png"Loaded vs unloaded
account.profile().get_opt().await? returning None doesn't by itself tell you why: prefetch might never have been called, or it ran and simply found no matching child. OneToOne<C>::is_loaded() still answers the "did prefetch run" question directly, read off the underlying field:
if !account.profile.is_loaded() { // never called .prefetch_related("profile")}if account.profile.is_loaded() && account.profile().get_opt().await?.is_none() { // prefetched, but this account has no profile row}Ambiguity errors
The back-link discovery requires exactly one UNIQUE FK on the child pointing at the parent. If the child has two unique FKs to the same parent (rare but possible), prefetch_related errors loudly naming the candidates and suggesting you either rename one or drop the unique attribute. If the child has no unique FK to the parent, the error tells you to add #[umbral(unique)].
Cross-crate back-link: auth_user.profile()
OneToOne<C> as a struct field needs the parent type to be in your crate or one you can edit. That doesn't fit AuthUser, which lives in umbral-auth and you don't want to fork. For exactly this case the derive macro emits a trait-based reverse-OneToOne accessor alongside the existing reverse-FK trait:
// In your app crate.#[derive(Model)]pub struct Profile { pub id: i64, #[umbral(unique, on_delete = "cascade")] pub user: ForeignKey<AuthUser>, // unique = o2o pub bio: String,} // Anywhere with an AuthUser in scope:let user: AuthUser = ...;if let Some(profile) = user.profile().get_opt().await? { println!("{}", profile.bio);}The macro emits both pub trait ProfileUserOneToOneReverse { fn profile(&self) -> Relation<Profile> } and impl ProfileUserOneToOneReverse for AuthUser { ... } in your crate, the same trait-on-foreign-type pattern that powers reverse-FK (user.profile_set()). Rust's orphan rule allows impl LocalTrait for ForeignType, so the accessor works without touching AuthUser at all. Relation<Profile> is chainable and awaitable: .await? (equivalently .get().await?) resolves to Result<Profile> — a required read that errors (RowNotFound) if no profile row exists — while .get_opt().await? resolves to Result<Option<Profile>> for the common case where the profile may not exist yet.
Naming is <child_snake>(), with <child_snake>_via_<field>() disambiguating when one child has multiple unique FKs to the same parent. The accessor is only emitted when the FK carries #[umbral(unique)]; a plain FK gets the _set() form but not the o2o form, since the cardinality is 0..N.
This is the right shape for request.user.profile.avatar-style flows in Rust: one chainable method call, no need for the user to know the back-link convention. Because it returns a Relation<Profile>, you can keep traversing from it too — see Relation traversal.
Template-side relation traversal is not implemented. Writing {{ user.profile.avatar }} directly in a minijinja template does NOT work: neither for cross-crate reverse-OneToOne nor for reverse-FK collections nor for the plain FK forward direction. user in templates is the JSON-serialized AuthUser (via user in templates); relation accessors are Rust async methods that don't survive serialization, and minijinja is synchronous so it can't .await anyway.
The workaround today: resolve the relation in the handler and pass the value into the template context explicitly:
let customer = user.0.customer().get_opt().await?; // resolve in Rust (Option — may be None)let customer_id = customer.as_ref().map(|c| c.id);render("me.html", &context!(customer_id)) // template uses `{{ customer_id }}`The eager-prefetch approach is shipped: resolve the relation with select_related (forward FK, including nested __ chains) or prefetch_related (reverse FK / M2M / reverse-O2O), then serialise the parent. The resolved child rides into the template context as a nested object, so {{ post.author.username }} works when the post was fetched with select_related("author"). What still doesn't work is a bare relation accessor (user.profile) on an unprefetched row, since that would need an async DB call mid-render.
Unique-constraint violations in the admin
Trying to create a second Profile for a user who already has one trips the UNIQUE constraint. The admin surfaces this as "A record with this user already exists." rather than a generic "database error". The column name comes from the parser in umbral-admin/src/util.rs::parse_unique_violation_column, which handles both SQLite (UNIQUE constraint failed: profile.user) and Postgres (Key (user)=(7) already exists.) message formats.
Identifying relations: a foreign key that is the primary key
Some tables have no surrogate id: their primary key is a foreign key to another table, one row per parent (a shared / identifying primary key — Django's OneToOneField(primary_key=True), Prisma's @id on a relation field, Rails' belongs_to on the PK). Declare it by marking the ForeignKey<T> field as the primary key:
#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize, Model)]pub struct Settings { #[umbral(primary_key)] pub user: ForeignKey<AuthUser>, // the PK and the FK are one column pub theme: String,}The column is emitted with the target's PK type (TEXT for a slug-keyed parent, BIGINT for an integer one), marked PRIMARY KEY, and given a REFERENCES constraint — one column doing both jobs, no AUTOINCREMENT (the key comes from the parent row). Settings::objects().create(...) stores the parent's key, reads it back through settings.user.id(), and a second row with the same key is rejected by the primary key. select_related("user") hydrates the parent as usual, and you filter by the raw key: settings::USER.eq("ada").
This is what inspectdb generates for an identifying relation it finds in a foreign database, so a Prisma/Django/Rails schema built this way ports without hand-editing.
Querying with a foreign key
The column constant emitted by #[derive(Model)] for a ForeignKey<T> field is a ForeignKeyCol, which accepts i64 comparisons:
// All posts by user with id = 42let posts = Post::objects() .filter(post::AUTHOR.eq(42)) .fetch() .await?; // All posts where reviewer is one of [1, 2, 3]let reviewed = Post::objects() .filter(post::REVIEWER.in_(&[1, 2, 3])) .fetch() .await?;Reading the raw ID and resolving the referenced row
ForeignKey<T> exposes .id() to read the raw primary key (a T::PrimaryKey - i64 for an integer-keyed target, String/Uuid otherwise) and .resolve(&pool) to fetch the referenced row. .id_ref() borrows the PK without cloning, and .set(pk) replaces the stored value:
let post = Post::objects() .filter(post::ID.eq(1)) .get() .await?; // Read the stored integer without a database round-trip.println!("author id = {}", post.author.id()); // Load the full User row from the database.let author: User = post.author.resolve(&pool).await?;println!("written by {}", author.name);.resolve(&pool) runs SELECT ... FROM user WHERE id = ? LIMIT 1. For a Postgres pool use .resolve_pg(&pg_pool).
Serialisation
Without select_related, ForeignKey<T> serialises and deserialises as the target's bare primary key in its native JSON shape - a number for an i64 PK, a string for a String/Uuid PK. The REST layer and the backup tool see that scalar: no nested object, no special envelope. After select_related has populated the resolved slot, it serialises as the full T object instead (see Eager loading).
{ "id": 1, "title": "Hello", "author": 42 }Eager loading with select_related
By default, a fetched Post carries only the raw integer in its author FK field. Accessing post.author.resolve(&pool) requires a second database round-trip. When you need the referenced row without an extra query (especially when rendering templates), call .select_related("author") on the QuerySet.
let post = Post::objects() .filter(post::ID.eq(42)) .select_related("author") .get() .await?; // Rust: the awaited accessor is served from the select_related cache,// zero further queries.let author = post.author().await?;println!("{}", author.username); // Template context: ctx["author"]["username"] is the username string.let ctx = serde_json::to_value(&post)?;// ctx["author"]["username"] == "alice"Under the hood, select_related runs a single batch SELECT ... FROM user WHERE id IN (...) after the main query. If the result set contains 50 posts, one batch query fetches all referenced users, not 50 individual queries.
Multiple FK fields
let post = Post::objects() .filter(post::ID.eq(42)) .select_related_many(&["author", "reviewer"]) .get() .await?; println!("{}", post.author().await?.username);println!("{}", post.reviewer().get_opt().await?.unwrap().username);Each named FK generates one batch query. Two FKs = two batch queries + the main query.
Template rendering
ForeignKey<T> serialises as a bare integer when resolved is None (the default without select_related). After select_related, it serialises as the full T object:
| State | serde_json::to_value(&post)["author"] |
|---|---|
Without select_related | 42 (bare integer) |
With select_related("author") | {"id":42,"username":"alice","name":"Alice"} |
This means {{ post.author.username }} in a minijinja template renders correctly when the template context was built from a post fetched with select_related("author").
Nested traversal: select_related("author__manager")
Chain FK hops with __. Each hop is one batched IN (...) query, so the budget is 1 + len(hops) regardless of row count, never N+1. The full chain is cached at every depth, so the awaited accessor at each hop reads from that cache with no further query:
// post.author.manager.manager: three hops in 4 queries total// (1 posts + 1 per hop), for any number of posts.let posts = Post::objects() .filter(post::TITLE.eq("third")) .select_related("author__manager__manager") .fetch() .await?; // Each accessor below is cache-served with zero further queries: the// chain was already populated by select_related.let author = posts[0].author().await?; // hop 1let manager = author.manager().get_opt().await?.expect("hop 2"); // manager is a nullable FKlet grandmgr = manager.manager().get_opt().await?.expect("hop 3");A NULL column anywhere in the chain bottoms out cleanly (the deeper hops simply don't load), no panic. An unknown hop name fails loudly, naming the bad field and the table it was looked up on.
select_related loads the chain in 1 + len(hops) batched queries. For the SAME relations resolved in a single query via a SQL JOIN (with INNER/LEFT/RIGHT control, nested __ paths, and an M2M hop), reach for join_related instead.
Still deferred
ON DELETEbehaviour: supported (seeon_delete/on_update); only changing the action on an already-created table needs a hand-written migration.- Non-
i64primary keys: supported. AForeignKey<T>storesT::PrimaryKey, soi64,String, andUuidtargets all work end-to-end throughselect_related,prefetch_related, andreverse::<C>(). The parent PK is bound through the same JSON-to-SQL coercion regardless of shape.
For the design rationale see arch.md §M9 and the design spec docs/superpowers/specs/2026-06-11-orm-relations-forms-and-joins-design.md.
Many-to-many relationships
Declare a many-to-many with the M2M<T> field type. The framework auto-creates a junction table named <parent_table>_<field> with (parent_id, child_id) columns at migration time, no through-model to write by hand.
use umbral::orm::M2M; #[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize, umbral::orm::Model)]#[umbral(table = "tag")]pub struct Tag { pub id: i64, pub name: String } #[derive(Debug, Clone, Default, sqlx::FromRow, serde::Serialize, serde::Deserialize, umbral::orm::Model)]#[umbral(table = "post")]pub struct Post { pub id: i64, pub title: String, // The M2M field carries no column on the post table; it's the // junction `post_tags(parent_id, child_id)`. `#[sqlx(skip)]` + // `#[serde(skip)]` keep it out of the row decode / default serialise. #[sqlx(skip)] #[serde(skip)] pub tags: M2M<Tag>,}The M2M<T> type is detected from the Rust type alone; no #[umbral(m2m)] marker is required. Add #[umbral(m2m = "<child_table>")] only when the child's table name isn't the snake_case default of T (e.g. the field's T is Tag2 but the table is tag).
M2M<T, P> carries a second type parameter P for the parent model's primary-key type, defaulting to i64. Override it when the parent's PK isn't i64 - pub tags: M2M<Tag, String> on a model whose PK is a String slug. The child's PK type comes from T::PrimaryKey and needs no annotation.
Loading the related set: prefetch_related("tags")
After the main query returns N parents, prefetch_related("tags") issues one batched join through the junction for all parents and populates each parent's M2M.resolved slot. See prefetch_related for M2M batching below for the full shape and the join_related single-query alternative.
let posts = Post::objects() .prefetch_related("tags") .fetch() .await?; for p in &posts { // Cache-served with zero further queries: prefetch_related populated it. for tag in p.tags().fetch().await? { println!("{}: {}", p.title, tag.name); }}Mutating the related set: add / remove / set / clear / fetch
On a loaded parent the M2M<T> field exposes async CRUD methods that write the junction table directly. Each routes through the ambient pool and works on both SQLite and Postgres:
// Lazy fetch through the junction (one round-trip).let tags: Vec<Tag> = post.tags.fetch().await?; // Link / unlink a single child. `add` is idempotent (ON CONFLICT DO NOTHING).post.tags.add(&tag).await?;post.tags.remove(&tag).await?; // Replace the entire set in one transaction.post.tags.set(&[&tag1, &tag2]).await?; // Remove every relation for this parent; returns the count removed.let n: u64 = post.tags.clear().await?;Each method is a no-op when the M2M slot is unattached (the parent hasn't been persisted, so there's no parent_id to filter on) - fetch returns an empty vec, the writers return Ok. add / remove / set / clear also fire an m2m_changed:<junction> signal so audit consumers can observe the change.
Counting the related set: annotate_count("tags")
To get the size of each parent's M2M set in the same query (no second round-trip), use annotate_count. It counts junction rows per parent via a correlated subquery. See Aggregates › Counting an M2M relation:
let rows = Post::objects() .annotate_count("tags") // COUNT(*) over post_tags as "tags_count" .fetch_annotated() .await?;M2M in a form: ModelMultiChoice
#[derive(umbral::forms::Form)] on a model with an M2M<T> field renders a multi-<select> and writes the selected ids to the junction after the parent insert, atomically. See Forms learn relations › M2M.
Reverse FK accessors
For every ForeignKey<Parent> field on a derived Child, the macro emits a method on the Parent type:
#[derive(umbral::orm::Model)]#[umbral(table = "user")]pub struct User { pub id: i64, pub name: String } #[derive(umbral::orm::Model)]#[umbral(table = "comment")]pub struct Comment { pub id: i64, pub body: String, pub author: ForeignKey<User>,} // Macro emits: impl User { pub fn comment_set(&self) -> QuerySet<Comment> }let comments: Vec<Comment> = alice.comment_set().fetch().await?; // Compose with the rest of the QuerySet API:let recent = alice .comment_set() .filter(comment::ID.gt(last_seen)) .order_by(comment::ID.desc()) .limit(10) .fetch() .await?;The accessor name is <snake_case(Child)>_set. When one Child has multiple FKs to the same Parent (e.g. author: FK<User> AND reviewer: FK<User>), the accessor names disambiguate to <child>_via_<field>_set (user.post_via_author_set(), user.post_via_reviewer_set()).
Limitations: parent type must be local (Rust's orphan rule on inherent impls); parent's primary-key type must implement Into<sea_query::Value> (every built-in PK type does).
Declared ReverseSet<Child> + batched prefetch_related
The _set() accessor above fetches one parent's children on demand. When you have N parents and want their children without an N+1 fetch loop, declare a typed ReverseSet<Child> field on the Parent and batch-load it with prefetch_related, fetching every parent's children in one extra query:
use umbral::orm::{ForeignKey, ReverseSet}; #[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize, umbral::orm::Model)]#[umbral(table = "parent")]pub struct Parent { pub id: i64, pub name: String, // The reverse collection. `reverse_fk = "parent"` names the FK // column on Child that points back here. It carries no column on // the parent table, so skip it from the row decode + serialise. #[sqlx(skip)] #[serde(skip)] #[umbral(reverse_fk = "parent")] pub child_set: ReverseSet<Child>,} #[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize, umbral::orm::Model)]#[umbral(table = "child")]pub struct Child { pub id: i64, pub label: String, pub parent: ForeignKey<Parent>, // the FK named by reverse_fk} // One query for the parents, ONE batched query for ALL their children.let parents = Parent::objects() .prefetch_related("child_set") .fetch() .await?; for p in &parents { // .child_set().fetch().await? is cache-served after prefetch: an // empty vec for a childless parent, zero further queries either way. for child in p.child_set().fetch().await? { println!("{}: {}", p.name, child.label); }}prefetch_related flows reverse-FK (ReverseSet), M2M, and reverse-O2O (OneToOne<C>) through the same dispatch. See prefetch_related for M2M batching.
Generic instance accessor: instance.reverse::<Child>()
The macro-emitted comment_set() only exists because the Child's ForeignKey<Parent> is visible where the Child is derived. When the Parent lives in another crate (e.g. AuthUser in umbral-auth) the macro can't enumerate its children, so there's a zero-declaration runtime accessor on every model instance that reaches a parent's children generically. The Child type is named at the call site; the FK column on the Child is discovered at runtime from Child::FIELDS:
use umbral::prelude::*; // brings ReverseRelations into scope // Returns a real, chainable QuerySet<Comment> filtered to children// whose FK points at this instance; no ReverseSet field required.let comments: Vec<Comment> = alice.reverse::<Comment>()?.fetch().await?; // Chains exactly like any QuerySet:let recent = alice .reverse::<Comment>()? .filter(comment::ID.gt(last_seen)) .order_by(comment::ID.desc()) .fetch() .await?; let n = alice.reverse::<Comment>()?.count().await?;reverse::<C>() returns Result<QuerySet<C>, ReverseError>: the FK discovery and parent-PK read are synchronous and fallible, so they resolve up front; the QuerySet<C> it yields stays lazy and awaitable. Discovery scans C::FIELDS for the field whose fk_target is this parent's table; exactly one match is required. Zero matches (C has no FK back) and two or more (ambiguous) both error loudly. For the ambiguous case, name the column explicitly:
// Child has TWO FKs to Parent (author AND reviewer):let authored = user.reverse_via::<Comment>("author")?.fetch().await?;reverse_via validates that the named column exists on C and is an FK to this parent's table. The parent PK is bound through the same JSON-to-SQL coercion as the rest of the relation machinery, so i64, String, and Uuid PKs all work; a PK that genuinely can't be read or bound surfaces as a clean ReverseError::NonI64Pk rather than mis-binding.
Batched reverse-FK with no declared field: prefetch_map::<Child>()
.prefetch_related("child_set") needs the ReverseSet<Child> field above because it writes its batched result into that field — there's nowhere else on the struct to put it. prefetch_map::<Child>() is the batched counterpart of reverse::<Child>(): it names the child type at the call site instead of a string field name, so it needs no declared field on the parent at all, and hands the batched children back explicitly instead of mutating a struct field:
use umbral::orm::ForeignKey; // Developer has NO ReverseSet<Achievement> field.#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize, umbral::orm::Model)]pub struct Developer { pub id: i64, pub name: String } #[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize, umbral::orm::Model)]pub struct Achievement { pub id: i64, pub title: String, pub developer: ForeignKey<Developer>,} // One query for the developers, ONE batched query for ALL their achievements.let prefetched = Developer::objects() .prefetch_map::<Achievement>() .fetch() .await?; for dev in &prefetched.parents { for ach in prefetched.children_of(dev) { println!("{}: {}", dev.name, ach.title); }}prefetched.children_of(&dev) reads a &[Achievement] slice — empty (not an error) for a childless parent. Prefetched<T, C> owns both parents: Vec<T> and the batched children keyed by parent PK; there's no hidden global or per-instance cache, and it composes with the rest of the chain (.filter(...).order_by(...).prefetch_map::<C>() still costs exactly one parent query + one child query). When Child has more than one FK back to the parent, name it explicitly with .via(...), mirroring reverse_via:
let via_mentor = Developer::objects() .prefetch_map::<Pairing>() .via("mentor") .fetch() .await?;Scope: reverse-FK only, matching reverse::<C>(). An M2M or reverse-O2O relation without a declared field isn't covered by prefetch_map yet.
prefetch_related for M2M batching
The M2M counterpart of select_related for FKs. After the main query returns N parent rows, prefetch_related("tags") issues one batched join through the junction table for all parents, groups results by parent_id, and populates each parent's M2M.resolved slot.
let groups: Vec<Group> = Group::objects() .filter(group::ID.gt(0)) .prefetch_related("tags") .fetch() .await?; for g in &groups { // .tags().fetch().await? is cache-served after prefetch: an empty // vec for parents with no children, never a fresh query. for tag in g.tags().fetch().await? { println!("{}: {}", g.name, tag.label); }}Without .prefetch_related(...), g.tags().fetch() has no cache to serve from, so each call issues a per-parent query (the N+1 path); prefetch makes it cache-served instead.
.prefetch_related_many(&["tags", "categories"]) batches several relations at once.
Scope: M2M, ReverseSet<C> reverse-FK collections, and OneToOne<C> (see One-to-one relationships). All three flow through the same dispatch in hydrate_prefetch_related. Parents are bucketed by their PK as a JSON value, so i64, String/slug, and Uuid parent PKs all hydrate.
M2M via LEFT JOIN: join_related("<m2m_field>")
An alternative to prefetch_related for M2M: instead of one extra round-trip, fold the M2M load into the main SELECT via a double LEFT JOIN through the junction. One query, but the row set multiplies by avg M2M cardinality (a parent with 3 tags appears as 3 rows pre-dedup).
let posts: Vec<Post> = Post::objects() .filter(post::PUBLISHED.eq(true)) .join_related("tags") // double LEFT JOIN .fetch() .await?; for p in &posts { // Same shape as prefetch_related: cache-served, zero further queries. for tag in p.tags().fetch().await? { println!("{}: {}", p.title, tag.name); }}The framework dedups parents in the row decoder and collects each parent's M2M children into one set_m2m_resolved_json call. LEFT JOIN miss (parent with zero children) surfaces as Some(&[]), distinguishing "loaded, no children" from "not loaded" the same way prefetch_related does.
Composes with FK join_related in one query:
Post::objects() .join_related("category") // FK join (one-to-one with rows) .join_related("tags") // M2M join (multiplies row count) .fetch().await?;// each post has resolved category + tags slot in one round-trip.When to choose which:
prefetch_related("tags") | join_related("tags") | |
|---|---|---|
| Queries | 2 (parent IN + child IN) | 1 (double LEFT JOIN) |
| Row count | One per parent | parent × avg M2M cardinality |
| Network bytes | Compact (no parent dup) | Wider rows × duplication |
| Best for | List pages with non-trivial M2M cardinality | Hot path detail / small fixed-set M2Ms (tags-on-post) |
| Multi-M2M | Independent (each is one query) | Cartesian (m2m_a × m2m_b), measurable cost |
For most list views prefetch_related is the right default. Reach for join_related when the second round-trip dominates AND the M2M cardinality is small + bounded.
See also
- Deep joins: the full
join_related/inner_join_related/left_join_related/right_join_relatedsurface, INNER/LEFT auto-inference, nested__paths, and the M2M-hop chain. - Aggregates and annotate:
annotate_count("rel")/annotate_count_where/ M2M counts in one query, including auto-discovery of an undeclared relation. - Forms learn relations:
#[derive(Form)]turnsForeignKey/OneToOneinto aModelChoice,M2Minto aModelMultiChoice, and a#[umbral(choices)]enum into aSelect. - Forms (Form<T>): the general form derive, async
validate,ValidationErrors, and theForm<T>extractor. - Column types:
ForeignKey<T>DDL,#[umbral(choices)]enums, and thedefaultliteral rule.