Skip to content

Relationships

Fullfinity supports three types of relationships between models.

A relation is a record on the form path, an id on the data path

Section titled “A relation is a record on the form path, an id on the data path”

The same field is handed over two different ways depending on which side of the form boundary you are on, and mixing them fails only when someone opens the screen.

A ManyToOne default returns the record:

# Correct — the form can label the picker
owner = ManyToOne("User", default=lambda self: get_current_user(self), ...)
# Wrong — an id hydrates into a stub with no name, and the form refuses to render it
owner = ManyToOne("User", default=lambda self: env.user_id, ...)

A new-record form hydrates the default in memory. An id becomes an {id}-only stub with no display name, so the picker would show the raw record number — and because the row is transient, nothing can resolve it later.

_default_get returns records too, so take the id off before querying with it:

async def _default_get(cls, context=None):
defaults = await super()._default_get(context)
board = defaults.get("board") # this is a RECORD
if board:
board_id = board if isinstance(board, int) else board.id
...

Seed a form through the context, not through values. Put default_<field> in the action’s context and the server resolves it into the record for you:

return await get_action(
identifier="board_card_action",
ctx={"default_board": self.id}, # resolved to the record; the picker gets its label
)

Passing the value directly instead pins the bare id as an explicitly-set value, with the same result as a bad default.

Ids remain correct everywhere elseModel.create(board=4), Q(company_id=3), record.field_id. This rule is about the form boundary only.

A foreign key relationship where many records point to one.

class Order(Model):
customer = ManyToOne(
"Contact", # Related model
related_name="orders", # Reverse relation name
on_delete="CASCADE", # Delete behavior
required=True
)
OptionBehavior
CASCADEDelete related records
SET NULLSet field to NULL
RESTRICTPrevent deletion if related records exist

on_delete is enforced by the database, as part of the foreign key the framework creates for the relation — deleting a record issues a plain DELETE, and Postgres performs the cascade or the null. This holds wherever the relation is declared: a relation you contribute to another module’s model with __inherit__ gets its foreign key exactly like one declared on your own model, so its on_delete behaves identically.

The practical consequence is that the behaviour is real rather than advisory — a RESTRICT relation makes the delete fail, and a CASCADE relation removes children the ORM never loaded. Choose the option for what should happen to the data, not for what your code happens to do before deleting.

# Create with relation
contact = await Contact.filter(id=1).get()
order = await Order.create(customer=contact, name="ORD-001")
# Or by ID
order = await Order.create(customer=1, name="ORD-002")
# Access related object
order = await Order.filter(id=1).get()
await order.fetch_related("customer")
print(order.customer.name)
print(order.customer.id) # read the related id via the fetched record
# Filter by relation
orders = await Order.filter(customer__name__icontains="Acme").all()

A relation’s dropdown offers every record of the related model. That is rarely what the field means: a payroll journal is a Miscellaneous journal, a cart-recovery email needs a template written against SaleOrder, a lot has to belong to the line’s product. Declare that scope on the field and every picker bound to it inherits it:

payroll_journal = ManyToOne("Journal", related_name="payroll_configs", on_delete="SET NULL",
description="Payroll Journal",
filter="Q(type='Miscellaneous')")
lot = ManyToOne("Lot", related_name="transfer_lines", on_delete="RESTRICT",
description="Lot / Serial",
filter="Q(product=product_variant__product)")

The scope is a Q-expression string, not a built Q object, because the right-hand side routinely names a field of the record being edited — a value that does not exist until there is a record on screen. ManyToOne, OneToOne and ManyToMany accept it; OneToMany does not (see below).

Left of the = is the related model; right of it is the record being edited. Q(product=product_variant__product) searches Lot for rows whose product matches the editing record’s product_variant.product. Inside an embedded line, _parent. reaches the parent form: Q(company=_parent.company). Both are resolved against the live form values, so the options follow what the user has typed.

A reference with no value drops its condition, leaving the rest of the scope to apply. Before a product is chosen, “lots belonging to this line’s product” has no subject, so it stops constraining rather than emptying the picker — a dropdown with no options reads to the user as “there is no data” and nothing explains otherwise. “No value” means null or missing, not falsy: an unticked Boolean is a real answer and still narrows. Where a pairing must genuinely hold, enforce it with a Model.constraint at the write, which can say why — a picker hiding rows is an affordance, never a guarantee.

Declaring it on the field, rather than in each view, is the point: view composition stamps it onto every relation picker bound to the field — Form, List, embedded line grid, Kanban, Search — so a view inherits the scope without saying anything, exactly as it inherits the field’s description, required and groups. A view added next year cannot forget it.

A view still has the last word, because a few scopes really are per-screen — one contact field offers customers on an invoice and vendors on a bill:

The view’s nodeWhat the picker uses
no filter:the field’s declared scope
filter: Q(...)the view’s scope, instead of the field’s — not intersected
filter: falsenothing; deliberately unscoped here

Override rather than intersection, because the two-scopes case above would AND to an empty dropdown. If a view narrows further, write the whole condition.

A mistyped scope has no loud failure mode — it reaches the query, matches nothing, and shows an empty dropdown with no explanation. So both halves are checked for you: each Q key must resolve on the target model, and each right-hand reference on the source model (that side names the record being edited, not the model being searched).

Three places run that check, all off one validator, so they cannot disagree:

WhereCoversWhen
check --only refsevery filter= declared on a fieldpre-commit / CI, no database
Saving a viewa view’s own filter: propertyat save, and at install/-u
core/tests/test_relation_filters.pythe declared set, plus that composition really stamps ittest suite

Literals, ambient bindings (uid, cid, term) and _parent. paths are values rather than fields of the source model, so they are skipped rather than resolved.

A OneToMany renders as an embedded grid of records that already exist, where filter means which of these rows to show — a different question that happens to share a name, and one that belongs to the screen showing them. Declaring it raises; author it on the view node instead.

A one-to-one relationship, similar to ManyToOne but with a UNIQUE constraint.

class User(Model):
name = Char(max_length=255)
profile = OneToOne(
"UserProfile",
related_name="user",
on_delete="CASCADE"
)
AspectManyToOneOneToOne
ConstraintForeign keyForeign key + UNIQUE
Forward accessSingle objectSingle object
Reverse accessList of objectsSingle object
# Create with relation
profile = await UserProfile.create(bio="Developer")
user = await User.create(name="John", profile=profile)
# Access forward (same as ManyToOne)
user = await User.filter(id=1).get()
await user.fetch_related("profile")
print(user.profile.bio)
# Access reverse (returns single object, not list)
profile = await UserProfile.filter(id=1).get()
await profile.fetch_related("user")
print(profile.user.name) # Single object, not list

The reverse of ManyToOne. Automatically created from related_name.

contact = await Contact.filter(id=1).get()
await contact.fetch_related("orders")
for order in contact.orders:
print(f"Order: {order.name}")

A count, total, average or extreme over a collection is declared on the field and resolved by the database. No calculate method, no fetch_related, and no child rows crossing the wire:

lesson_count = Integer(aggregate=Count("lessons"), store=True)
certified_count = Integer(aggregate=Count("enrollments", filter=Q(state="Certified")), store=True)
posted_total = Monetary(aggregate=Sum("lines__amount", filter=Q(state="Posted")),
currency_field="currency", store=True)
last_activity = Datetime(aggregate=Max("events__occurred_at"), store=False)

The argument names a relation on this modelSum/Avg/Min/Max add the child field to aggregate (lines__amount). That is different from what the same class means in annotate(), where it names a column of the table being queried, and the difference matters because the query here runs against the child.

Why it is not just tidier. The compute it replaces pulls every child row with all of its columns and builds one object per row to produce a single number. Measured against a real table — 292 parents, 5,576 children, 24 columns — fetching the children takes 18.5ms and the grouped aggregate 1.4ms.

Statistics over the same relation coalesce into one query. They share a synthesised compute, so three counts over enrollments ride in a single grouped statement with a FILTER (WHERE …) clause each — a third and fourth statistic cost 0.06ms on top of the first, against 1.4ms each as separate queries.

filter= is an ordinary Q over the child’s own fields, foreign keys included (Q(account=account.id) tests the account_id column). It is rendered by the same code as any other Q, so it cannot drift from ordinary filter semantics.

Rules the declaration is checked against at load, because each of these fails silently otherwise:

AggregateField must beNotes
CountIntegera count into a Float reads as 3.0
Sum, AvgInteger, Float, Monetarya Monetary must declare currency_field
Min, Maxthe child field’s own typea Max over a Date cannot land in an Integer

Works on OneToMany and ManyToMany. A ManyToOne/OneToOne is refused — an aggregate over one record is always 0 or 1.

A stored aggregate stays current on its own. It registers the relation and the filter’s fields as dependencies, so flipping an enrolment to Certified refreshes certified_count even though no row was added or removed. That second half is the one a hand-written compute usually forgets to declare.

Reads apply the same company isolation, record rules and archived filter as reading the children would, so the number never counts rows the caller could not see.

Counting and totalling what you are HOLDING — count_of / sum_of

Section titled “Counting and totalling what you are HOLDING — count_of / sum_of”

aggregate= answers for the rows in the database. When you need the children this recordset is holding right now — including ones not saved yet — the answer can only come from memory:

await order.fetch_related("lines")
order.count_of("lines") # the number, nothing built
await order.sum_of("lines", "amount") # the total, nothing built
order.count_of("lines", Q(state="Reviewed")) # both take an optional Q filter

A form with unsaved lines has nothing in the database to group, which is why User.groups_count is computed this way — as a COUNT over the join table it read 0 for every new user and ignored unsaved edits.

Both read from the loaded page rather than materialising records: count_of is a single index lookup, and sum_of walks a column of values instead of building a record per child. sum_of is await because a non-stored computed child field has no column until its compute has run for the page; for a stored field the await does nothing.

Fetch first — both raise if the relation was never loaded, rather than reporting zero. Reading an unfetched collection measures as empty, so len(record.children or []) and sum(c.amount or 0 for c in (record.children or [])) quietly yield 0 on a page that simply did not prefetch. That is harmless when you are looking at the value and silently wrong when you are storing it — a zero total is indistinguishable from a real one.

For a stored count or total, prefer aggregate=: it removes the fetch, which these two still pay for.

# Get contacts with at least one paid order
contacts = await Contact.filter(orders__status="paid").all()

Many records can relate to many other records.

class Product(Model):
tags = ManyToMany(
"Tag",
related_name="products",
through="fkproducttag" # Through table name
)

Use command arrays with update() to link, unlink, create, or modify related records.

CommandSyntaxDescription
link["link", id]Link an existing record by ID
unlink["unlink", id]Detach a record — the record itself survives
create["create", {...}]Create a new record and link it
delete["delete", id]Destroy the related record entirely
edit["edit", id, {...}]Edit an already-linked record
set["set", [id1, id2, ...]]The collection becomes exactly these IDs
clear["clear"]Detach every related record

unlink and delete are the pair worth reading twice: one detaches, the other destroys the row. Same for clear (detach all) versus set (replace the set).

# Link multiple tags
await product.update(
tags=[["link", 1], ["link", 2], ["link", 3]]
)
# Unlink a tag
await product.update(
tags=[["unlink", 2]]
)
# Create and link a new tag
await product.update(
tags=[["create", {"name": "New Tag", "color": "#FF0000"}]]
)
# Mix of operations
await product.update(
tags=[
["link", existing_tag_id], # Link existing
["create", {"name": "Sale"}], # Create new
["unlink", old_tag_id], # Unlink
]
)
# Replace all tags with a new set
await product.update(
tags=[["set", [1, 2, 3]]] # Now only tags 1, 2, 3 are linked
)
# Remove all tags
await product.update(
tags=[["clear"]] # Unlink all
)

Each command is a list with a fixed number of parts — the wrong arity raises ValidationError. A bare id is auto-wrapped into a single-element list for the id-list commands (link/unlink/delete/set), so ["link", 5] and ["link", [5]] are equivalent; create and edit are never wrapped (their second part is a dict / id).

CommandExact shapePartsNotes
create["create", {vals}]2Second part must be a dict — there is no None id slot.
edit["edit", id, {vals}]3Edits an already-linked record; errors if id isn’t currently linked.
delete["delete", [ids]]2Deletes the related record(s) entirely (M2M: drops the through rows).
link["link", [ids]]2Links existing records; never creates. Already-linked ids are skipped.
unlink["unlink", [ids]]2Detaches. See O2M note below.
clear["clear"]1No arguments. Detaches all related records.
set["set", [ids]]2The collection becomes exactly ids. No-ops if unchanged.

A relational field accepts only command notation. A bare list — of records or of ids — raises ValidationError naming the field and the shapes it expects; it is never interpreted as “replace with these”.

unlink/clear/set on a OneToMany depend on the child’s FK: for a O2M, “unlinking” a child means clearing its foreign key — but if that FK is required the child can’t be orphaned, so the engine deletes it instead. If the FK is optional it is set to None (detached). For M2M these commands only ever drop through-table rows; the related records themselves are untouched (except delete).

After Model.create(), ManyToMany fields are RecordList objects. Use update() with command arrays to modify relations:

# Create a new record
message = await Message.create(
content="Hello",
author=author,
)
# ManyToMany fields are RecordList after create
# Use update() with command arrays to add relations
await message.update(
contacts=[["link", cid] for cid in contact_ids],
attachments=[["link", aid] for aid in attachment_ids],
)

:::warning Important Do NOT use .add() or .remove() methods on ManyToMany fields - they don’t exist in this ORM. Always use update() with command arrays. :::

ManyToMany and OneToMany fields support a default=, written in command notation (the same verbs as update()). The default returns a list of commands; the engine persists them on create and resolves them for the new-record form.

class Product(Model):
# M2M default — link an existing record (["link", [ids]]):
routes = ManyToMany("Route", through="FkProductRoute", related_name="products",
default=lambda self: self._default_routes())
async def _default_routes(self):
buy = await get_model("Route").filter(identifier="buy_route").first()
return [["link", [buy.id]]] if buy else None
class Order(Model):
# O2M default — create default child rows (["create", {vals}]):
lines = OneToMany("OrderLine", related_name="order",
default=lambda self: self._default_lines())

Write the default as a normal self-bound method, like any other field default. It is evaluated against the record being built, on both paths:

  • Persistencecreate() evaluates the default for any relational field the caller didn’t supply and hands the commands to save_related(), which consumes them directly: ["link", [ids]] links, ["create", {vals}] creates the child row.
  • UI prefill — the new-record form path (Model.new()) evaluates the same default and resolves the commands into records so the form can render them: an M2M default becomes chips, an O2M default becomes draft line rows.

An empty collection does not suppress a default on a record that doesn’t exist yet. A new-record form posts every field in its view, so an untouched M2M arrives as []; that is treated as “nothing chosen yet”, not as a deliberate clear. Clearing the field on a saved record is still honoured.

ManyToMany and OneToMany fields return RecordList objects - a list subclass with convenience properties.

product = await Product.filter(id=1).prefetch_related("tags").first()
# Get list of IDs from related records
tag_ids = product.tags.ids # [1, 2, 3]
# Use directly in filters
related = await Something.filter(tag_id__in=product.tags.ids).all()
# Works like a normal list
for tag in product.tags:
print(tag.name)
PropertyReturnsDescription
.idsList[int]List of IDs from all records
product = await Product.filter(id=1).get()
await product.fetch_related("tags")
for tag in product.tags:
print(tag.name)
tag = await Tag.filter(id=1).get()
await tag.fetch_related("products")
for product in tag.products:
print(product.name)

For ManyToMany relationships where a model relates to itself (e.g., Group → Group), you must use the through_fields parameter to specify which FK is which.

# Junction table
class FkGroupImpliedGroup(Model):
_verbose_name = "Group Implied Group"
group = ManyToOne("Group", related_name="implied_group_links", on_delete="CASCADE")
implied_group = ManyToOne("Group", related_name="implying_group_links", on_delete="CASCADE")
# Model with self-referential M2M
class Group(Model):
_verbose_name = "User Group"
name = Char(max_length=255)
implied_groups = ManyToMany(
"Group",
through="FkGroupImpliedGroup",
through_fields=("group", "implied_group"), # (current_fk, related_fk)
related_name="implying_groups",
)

The through_fields tuple specifies:

  1. First element: The FK that points to the “owning” record (the record this field belongs to)
  2. Second element: The FK that points to the “related” records (what you’re linking to)

:::warning Required through_fields is required for self-referential ManyToMany relationships. Without it, the ORM cannot determine which FK represents the current record vs. the related records. :::

For models that form a parent/child tree via a self-referential ManyToOne (categories, locations, BoMs…), set _parent_field to the name of that field. The ORM then provides tree support automatically — you don’t hand-write any of it.

class ProductCategory(Model):
_verbose_name = "Product Category"
_parent_field = "parent" # opt into the hierarchy primitive
name = Char(max_length=255, required=True)
parent = ManyToOne(
"ProductCategory",
related_name="child_categories",
on_delete="SET NULL",
)

Declaring _parent_field auto-injects:

  • parent_path — an indexed, stored, auto-maintained materialised path of ids with a trailing slash, e.g. "1/4/9/" for a node whose ancestors are 1 → 4. It is recalculated (and re-propagated to all descendants) whenever a node is created or reparented, via the calculated-field cascade. Because it is id-based it is immune to duplicate names across levels, and the trailing slash makes prefix matching boundary-safe ("1/4/" matches "1/4/9/" but never "1/40/").
  • a cycle guard — a constraint on the parent field that rejects making a node its own ancestor (raises UserError).

Filter a node and its whole subtree with the materialised path, or the child_of helper:

from fullfinity.engine.base import child_of
# All products in "Electronics" (id 4) or any descendant category:
electronics = await ProductCategory.filter(name="Electronics").first()
products = await Product.filter(
Q(category__parent_path__startswith=electronics.parent_path)
).all()
# Same thing via the helper (accepts a record or an id):
products = await Product.filter(await child_of(ProductCategory, electronics.id)).all()

The guard is also exported on its own for models that need it without the rest of the primitive (e.g. a self-referential field not named parent):

from fullfinity.engine.base import assert_no_parent_cycle
class Package(Model):
parent_package = ManyToOne("Package", related_name="packages", on_delete="CASCADE")
@Model.validate("parent_package")
async def validate_no_parent_cycle(self):
await assert_no_parent_cycle(self, "parent_package")
order = await Order.filter(id=1).get()
await order.fetch_related("customer")
print(order.customer.name)
order = await Order.filter(id=1).get()
await order.fetch_related("customer", "lines")
order = await Order.filter(id=1).get()
await order.fetch_related("customer__company__country")
print(order.customer.company.country.name)
# Bad: N+1 queries
orders = await Order.filter().all()
for order in orders:
await order.fetch_related("customer") # Query per order!
print(order.customer.name)
# Good: Single query with JOIN
orders = await Order.filter().prefetch_related("customer").all()
for order in orders:
print(order.customer.name) # Already loaded
orders = await Order.filter().prefetch_related(
"customer",
"lines",
"lines__product"
).all()
# Prefetch all immediate relations
orders = await Order.filter().prefetch_all().all()
# Orders for customers named "Acme"
orders = await Order.filter(customer__name="Acme Corp").all()
# Products in "Electronics" category
products = await Product.filter(category__name="Electronics").all()
# Orders for US customers
orders = await Order.filter(
customer__country__code="US"
).all()
# Invoices for customers with active subscriptions
invoices = await Invoice.filter(
customer__subscription__status="active"
).all()
# Orders for customers with 10+ employees
orders = await Order.filter(
customer__employee_count__gte=10
).all()
# Products with tags containing "sale"
products = await Product.filter(
tags__name__icontains="sale"
).all()
from fullfinity.engine.base import *
class Author(Model):
name = Char(max_length=255, required=True)
email = Char(max_length=255)
class Category(Model):
name = Char(max_length=100, required=True)
parent = ManyToOne("Category", related_name="children")
class Tag(Model):
name = Char(max_length=50, required=True)
color = Char(max_length=20, default="#6366F1")
class Book(Model):
title = Char(max_length=255, required=True)
author = ManyToOne("Author", related_name="books", required=True)
category = ManyToOne("Category", related_name="books")
tags = ManyToMany("Tag", related_name="books", through="fkbooktag")
published_date = Date()
# Usage
async def example():
# Create author and book
author = await Author.create(name="John Doe")
book = await Book.create(
title="Python Guide",
author=author
)
# Add tags
tag1 = await Tag.create(name="Programming")
tag2 = await Tag.create(name="Python")
await book.update(tags=[["link", tag1.id], ["link", tag2.id]])
# Query with prefetch
books = await Book.filter().prefetch_related("author", "tags").all()
for book in books:
print(f"{book.title} by {book.author.name}")
print(f"Tags: {', '.join(t.name for t in book.tags)}")
# Filter across relations
python_books = await Book.filter(
tags__name="Python"
).prefetch_related("author").all()

Relations are not lazily loaded on attribute access — the ORM is async, so traversing order.customer.name can’t do a hidden database round-trip. Load what you need first with fetch_related (single record) or prefetch_related (query):

order = await Order.filter(id=1).get()
await order.fetch_related("customer") # now order.customer is the record
print(order.customer.name)
# Nested paths in one call:
await order.fetch_related("customer__company__currency")

Accessing a relation you haven’t fetched returns an awaitable proxy — you can await order.customer to fetch it on the spot, and if order.customer: still works as an existence check (it reads the FK id without fetching).

If you read a field off a record you only hold by reference — a bare id, or a {id, …} payload from the client, i.e. a partial record — before it’s loaded, the ORM raises a clear error instead of returning a misleading None:

Field 'size' on Sequence is not loaded — this is a partial record
(loaded by reference). Fetch it before use: … fetch_related that path.

The fix is always to fetch first. (A brand-new record you’re building for create() is not partial, so its not-yet-set fields still read as None.)

You don’t have to fully load a record before fetching its relations. Calling fetch_related on a record you hold only by id/reference completes its own columns from the database first, then resolves the requested relations:

company = SomeModel_stub_or_reference # e.g. hydrated from {"id": 5}
await company.fetch_related("currency") # loads the company row, then currency
print(company.currency.symbol) # works

Fully-loaded records skip this entirely, so it adds no cost to normal access.