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

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.

Declaring a foreign key

Code
rust
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:

Code
sql
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:

Code
sql
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:

Code
rust
#[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 valueEmitsMeaning
"no_action" (default)no clauserefuse the delete/update if children exist
"cascade"ON DELETE CASCADEdelete/update children too
"restrict"ON DELETE RESTRICTblock immediately, no commit-time deferral
"set_null"ON DELETE SET NULLnull 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:

Code
rust
#[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:

Code
sql
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):

Code
rust
// 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>

Code
rust
#[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().await?). The OneToOne<T> type carries the FK value at runtime: construct with OneToOne::new(id), read with .id(), and (after select_related) .resolved() -> Option<&T>, symmetric with ForeignKey<T>.

Longhand spelling: #[umbral(unique)] ForeignKey<T>

Code
rust
#[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:

Code
sql
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:

Code
rust
let profile = Profile::objects()
.select_related("user")
.get(profile::USER.eq(user.id))
.await?;
// profile.user.resolved() is Some(&AuthUser)

Reading 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.

Info

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().await? directly on AuthUser, a one-call accessor for the related row. 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:

Code
rust
#[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:

Code
rust
let account = Account::objects()
.prefetch_related("profile")
.get(account::ID.eq(1))
.await?;
 
// account.profile.resolved() is Option<&Profile>
if let Some(profile) = account.profile.resolved() {
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:

Code
rust
serde_json::to_value(&account)["profile"]["avatar"] // "alice.png"

Loaded vs unloaded

OneToOne<C>::resolved() returns None both when prefetch wasn't called and when prefetch ran but found no matching child. Use is_loaded() to distinguish the two:

Code
rust
if !account.profile.is_loaded() {
// never called .prefetch_related("profile")
}
if account.profile.is_loaded() && account.profile.resolved().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)].

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:

Code
rust
// 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().await? {
println!("{}", profile.bio);
}

The macro emits both pub trait ProfileUserOneToOneReverse { async fn profile(&self) -> Result<Option<Profile>, sqlx::Error> } 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.

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 method call, one LIMIT 1 query, no need for the user to know the back-link convention.

Warning

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:

Code
rust
let customer = user.0.customer().await?; // resolve in Rust
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.

Querying with a foreign key

The column constant emitted by #[derive(Model)] for a ForeignKey<T> field is a ForeignKeyCol, which accepts i64 comparisons:

Code
rust
// All posts by user with id = 42
let 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:

Code
rust
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).

Code
json
{ "id": 1, "title": "Hello", "author": 42 }

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.

Code
rust
let post = Post::objects()
.filter(post::ID.eq(42))
.select_related("author")
.get()
.await?;
 
// Rust: resolved() is Some without a second query.
let author = post.author.resolved().expect("populated by select_related");
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

Code
rust
let post = Post::objects()
.filter(post::ID.eq(42))
.select_related_many(&["author", "reviewer"])
.get()
.await?;
 
println!("{}", post.author.resolved().unwrap().username);
println!("{}", post.reviewer.resolved().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:

Stateserde_json::to_value(&post)["author"]
Without select_related42 (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 unpacks into resolved() slots at every depth:

Code
rust
// 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?;
 
let author = posts[0].author.resolved().expect("hop 1");
let manager = author.manager.as_ref().unwrap().resolved().expect("hop 2");
let grandmgr = manager.manager.as_ref().unwrap().resolved().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 DELETE behaviour: supported (see on_delete / on_update); only changing the action on an already-created table needs a hand-written migration.
  • Non-i64 primary keys: supported. A ForeignKey<T> stores T::PrimaryKey, so i64, String, and Uuid targets all work end-to-end through select_related, prefetch_related, and reverse::<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.

Code
rust
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.

Info
First-class M2M (the `M2M` field, `prefetch_related`, `join_related`, the form `ModelMultiChoice`, and `annotate_count`) is the shipped path. `examples/shop/plugins/content/src/models.rs` (`Post.tags: M2M`) and `umbral_website/plugins/site_content/src/models.rs` (`BlogPost.tags: M2M`) are real reference uses.

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.

Code
rust
let posts = Post::objects()
.prefetch_related("tags")
.fetch()
.await?;
 
for p in &posts {
for tag in p.tags.resolved().unwrap_or_default() {
println!("{}: {}", p.title, tag.name);
}
}

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:

Code
rust
// 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.

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:

Code
rust
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:

Code
rust
#[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).

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:

Code
rust
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 {
// .resolved() is Some(&[Child]) after prefetch: an empty slice
// for a childless parent, never None.
for child in p.child_set.resolved().unwrap_or_default() {
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:

Code
rust
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:

Code
rust
// 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.

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.

Code
rust
let groups: Vec<Group> = Group::objects()
.filter(group::ID.gt(0))
.prefetch_related("tags")
.fetch()
.await?;
 
for g in &groups {
// .resolved() returns Some(&[Tag]) after prefetch: even an empty
// slice for parents with no children, never None.
for tag in g.tags.resolved().unwrap_or_default() {
println!("{}: {}", g.name, tag.label);
}
}

Without .prefetch_related(...), M2M.resolved() stays None and accessing the relation would issue a per-parent fetch (the N+1 path).

.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).

Code
rust
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: populated M2M slot.
for tag in p.tags.resolved().unwrap_or_default() {
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:

Code
rust
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")
Queries2 (parent IN + child IN)1 (double LEFT JOIN)
Row countOne per parentparent × avg M2M cardinality
Network bytesCompact (no parent dup)Wider rows × duplication
Best forList pages with non-trivial M2M cardinalityHot path detail / small fixed-set M2Ms (tags-on-post)
Multi-M2MIndependent (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_related surface, 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)] turns ForeignKey/OneToOne into a ModelChoice, M2M into a ModelMultiChoice, and a #[umbral(choices)] enum into a Select.
  • Forms (Form<T>): the general form derive, async validate, ValidationErrors, and the Form<T> extractor.
  • Column types: ForeignKey<T> DDL, #[umbral(choices)] enums, and the default literal rule.
ormforeign-keyrelationshipsschema