Skip to content

Calculated Fields

Calculated fields automatically calculate their values from other fields.

from fullfinity.engine.base import *
class Order(Model):
quantity = Integer(default=1)
unit_price = Monetary(default=0.0)
# Calculated field
total = Monetary(
calculate="_compute_total",
store=True
)
@Model.calculate("quantity", "unit_price")
async def _compute_total(self):
for record in self:
record.total = record.quantity * record.unit_price

A calculate method receives the whole recordset — iterate it

Section titled “A calculate method receives the whole recordset — iterate it”

Assign in every branch — nothing checks this for you. A field your method declares but never assigns on some record is left unset rather than reported, so a conditional must cover its else case with an explicit value. Don’t rely on the field “staying” at a default:

# CORRECT — the else branch assigns an explicit value
@Model.calculate("delivery_address")
async def _compute_delivery_label(self):
for record in self:
if record.delivery_address:
record.delivery_label = record.delivery_address.name
else:
record.delivery_label = "" # ← explicit; not "leave it unset"

Declares which fields trigger recomputation:

@Model.calculate("quantity", "unit_price")
async def _compute_total(self):
for record in self:
record.total = record.quantity * record.unit_price

When quantity or unit_price changes, _compute_total is called automatically.

A calculated field’s value belongs to its calculate method, not to the user — so readonly defaults to True for any field with a calculate and no setter. You don’t declare it, and you shouldn’t repeat it in the view:

# Readonly everywhere — forms, list inline editing, and import. Nothing to declare.
total = Monetary(calculate="_compute_total", store=True)
# Writable: the setter routes the write back to whatever the value derives from.
full_name = Char(calculate="_compute_full_name", setter="_set_full_name")
# Explicit always wins, in either direction.
estimate = Float(calculate="_compute_estimate", readonly=False) # user may override

This is the same rule related_field already follows, and for the same reason: without it a write is accepted and then discarded by the next recalculate, so a form input on such a field is a dead end — the user types a number, saves, and it reverts the moment any dependency changes.

The flag travels in the model metadata, so every surface honours it from one declaration: form inputs render read-only, list cells refuse inline editing, and the import endpoint drops the column rather than reporting a success that never landed.

  • Saved in database
  • Computed on save
  • Can be filtered/sorted
  • Better for frequently accessed data
total = Monetary(calculate="_compute_total", store=True)
  • Computed on-the-fly
  • Not in database
  • Cannot be filtered
  • Better for derived display values
  • Only hydrated on root-level records by default
display_name = Char(
max_length=255,
calculate="get_display_name",
store=False
)

:::warning Hydration Depth Non-stored calculated fields are only automatically hydrated on root-level query results. For nested records (via prefetch_related), you must explicitly request them using with_fields().

# Root level: margin is hydrated ✓
products = await Product.filter().all()
print(product.margin) # Works
# Nested: margin is NOT hydrated ✗
order = await Order.filter(id=1).prefetch_related("lines__product").first()
print(order.lines[0].product.margin) # Raises AttributeError!
# Fix: Use with_fields to explicitly request nested calculated fields
order = await Order.filter(id=1).with_fields("lines__product__margin").first()
print(order.lines[0].product.margin) # Works ✓

See Querying Data - Select Specific Fields for more details. :::

Calculated fields can depend on other calculated fields:

class Order(Model):
quantity = Integer(default=1)
unit_price = Monetary(default=0.0)
discount_percent = Float(default=0.0)
tax_rate = Float(default=10.0)
# Subtotal depends on quantity and price
subtotal = Monetary(calculate="_compute_subtotal", store=True)
# Discount depends on subtotal
discount_amount = Monetary(calculate="_compute_discount", store=True)
# Total depends on subtotal, discount, and tax
total = Monetary(calculate="_compute_total", store=True)
@Model.calculate("quantity", "unit_price")
async def _compute_subtotal(self):
for record in self:
record.subtotal = record.quantity * record.unit_price
@Model.calculate("subtotal", "discount_percent")
async def _compute_discount(self):
for record in self:
record.discount_amount = record.subtotal * (record.discount_percent / 100)
@Model.calculate("subtotal", "discount_amount", "tax_rate")
async def _compute_total(self):
for record in self:
after_discount = record.subtotal - record.discount_amount
record.total = after_discount * (1 + record.tax_rate / 100)

Override update(). A stored calculated field reaches it like any other write — whether a caller set the value or the engine derived it — so one override covers both:

class InvoiceHook(Model):
__inherit__ = "FinancialDocument"
async def update(cls, records, **vals):
result = await super().update(records, **vals)
if vals.get("payment_status") == "Paid":
for record in records:
await record.notify_accounts()
return result

This is the seam for anything that follows from a derived value: an invoice becoming fully paid, a stock rollup crossing a reorder point, an order total passing an approval threshold. You do not need to know what caused the recalculate — a payment reconciling three models away still arrives here as payment_status in vals.

Two things to keep in mind:

  • Make the reaction idempotent. A recalculate can report the same value more than once for a single business event, so key your effect on something stable rather than assuming one call.
  • Don’t write the same field back. It is a function of its dependencies; the next recalculate overwrites whatever you set. React by writing elsewhere.

Compute from related records. fetch_related can stay outside the loop — it batches the fetch across the whole recordset — then assign per record inside:

class Invoice(Model):
customer = ManyToOne("Contact", related_name="invoices")
# Depends on lines (OneToMany)
total = Monetary(calculate="_compute_total", store=True)
@Model.calculate("lines", "lines__amount")
async def _compute_total(self):
await self.fetch_related("lines") # batches for every record
for record in self:
record.total = sum(line.amount for line in (record.lines or []))
class InvoiceLine(Model):
invoice = ManyToOne("Invoice", related_name="lines")
quantity = Integer(default=1)
unit_price = Monetary()
amount = Monetary(calculate="_compute_amount", store=True)
@Model.calculate("quantity", "unit_price")
async def _compute_amount(self):
for record in self:
record.amount = record.quantity * record.unit_price

The dependency can be a ManyToMany just as well — declare the M2M field, and a store=True value recalculates whenever the set of linked records changes (a member is linked or unlinked), not only at create. This makes a stored, indexed signature of a relation a reliable lookup key:

class Basket(Model):
tags = ManyToMany("Tag", related_name="baskets")
# A canonical, indexed signature of the linked tags — filter by it to find the basket
# for a given tag combination. It stays correct as tags are added or removed.
tag_signature = Char(calculate="_compute_tag_signature", store=True, index=True)
@Model.calculate("tags")
async def _compute_tag_signature(self):
for record in self:
await record.fetch_related("tags")
ids = sorted(tag.id for tag in (record.tags or []))
record.tag_signature = ":".join(str(i) for i in ids)

Read the relation with fetch_related (or iterate the field) inside the method — the value you compute always reflects the relation’s current members, including links written after the record was first created.

Simpler alternative for single-value lookups:

class Contact(Model):
company = ManyToOne("Company", related_name="contacts")
# Auto-computed from relation
company_name = Char(
max_length=255,
related_field="company__name",
store=False
)
country_code = Char(
max_length=10,
related_field="company__country__code",
store=False
)

get_display_name is a calculated field like any other — it receives the recordset and must assign display_name on every record. It returns nothing: the framework supplies the method’s return value, so an implementation only populates the field.

class Contact(Model):
first_name = Char(max_length=100)
last_name = Char(max_length=100)
email = Char(max_length=255)
async def get_display_name(self):
for record in self:
if record.first_name and record.last_name:
record.display_name = f"{record.first_name} {record.last_name}"
elif record.email:
record.display_name = record.email
else:
record.display_name = f"Contact #{record.id}"

A statistic over a relation is not a compute — declare it with aggregate= and the database resolves it. No method, no fetch_related, and no child rows fetched:

class TaskList(Model):
name = Char(max_length=255)
task_count = Integer(aggregate=Count("tasks"), store=False)
completed_count = Integer(aggregate=Count("tasks", filter=Q(status="Done")), store=False)
progress = Float(calculate="_compute_progress", store=False)
@Model.calculate("task_count", "completed_count")
async def _compute_progress(self):
for record in self:
total = record.task_count or 0
record.progress = ((record.completed_count or 0) / total * 100) if total else 0

Both counts share one grouped query with a FILTER clause each, and progress is then arithmetic over two numbers this model already has — no second pass over materialised children. See Relationships for the full aggregate= rules.

The version to reach for a compute is the one that isn’t an aggregate: a ratio, a weighted figure, anything needing the children themselves.

One method can still calculate several fields — but then it must assign all of them on every record, in every branch. Nothing verifies this for you: a declared field the method skips is simply left unset.

@Model.calculate("lines", "lines__quantity", "lines__unit_price")
async def _compute_totals(self):
await self.fetch_related("lines")
for record in self:
lines = record.lines or []
record.net = sum((l.quantity or 0) * (l.unit_price or 0) for l in lines)
record.heaviest = max((l.weight or 0) for l in lines) if lines else 0

A compute is handed the whole page: self is the recordset, and every record on screen is computed in one call. So a query written inside the loop costs a round trip per row:

# WRONG — one query per record. A 40-row list issues 40 queries to fill one column,
# and because the field is non-stored it pays that again on every render.
@Model.calculate()
async def _compute_open_count(self):
for record in self:
record.open_count = await Ticket.filter(team=record, closed=False).count()

Most calculated fields are store=False, so this is not a one-off cost at write time — it is paid on every list render, every time. A compute doing two or six such queries multiplies from there. It will not show up in a unit test, which computes a single record; it shows up as a slow page that is hard to attribute to anything.

This is enforced: ./fullfinity-server check --only computes (part of the default check) fails the build on a query inside for ... in self. There are three right answers.

1. Declare an aggregate= on the field — the best option when it fits. The database groups it, no child rows are fetched, and several statistics over the same relation coalesce into one query:

open_count = Integer(aggregate=Count("tickets", filter=Q(closed=False)), store=False)

An aggregate needs a declared collection, and its filter may only test the child’s own columns — a SQL FILTER clause has nowhere to put a join, so Q(stage__is_done=False) is refused rather than silently ignored.

2. Group ONE query over the page’s ids — for exactly that case, where the filter walks a relation:

@Model.calculate()
async def _compute_open_count(self):
records = [record for record in self]
for record in records:
record.open_count = 0
ids = [record.id for record in records if record.id]
if not ids:
return
rows = (
await Ticket.filter(Q(team__in=ids), stage__is_done=False)
.order_by()
.annotate(open_count=Count())
.group_by("team__id")
.all()
)
by_team = {row["team__id"]: row.get("open_count") or 0 for row in rows}
for record in records:
record.open_count = by_team.get(record.id, 0)

3. await self.fetch_related(...) — when the rows themselves are needed. On a recordset this loads the relation for the whole page, one query per level, so it is not flagged:

@Model.calculate("lines", "lines__stage")
async def _compute_done_lines(self):
await self.fetch_related("lines", "lines__stage") # one query per level, whole page
for record in self:
record.done_lines = sum(1 for l in (record.lines or []) if l.stage and l.stage.is_done)

record.fetch_related(...) inside the loop is equally fine — on a record belonging to a loaded page it loads the relation for that whole page, not for the one row.

class Product(Model):
type = Selection(choices=["Physical", "Digital"], default="Physical")
weight = Float(default=0.0)
file_size = Integer(default=0)
shipping_required = Boolean(calculate="_compute_shipping", store=True)
@Model.calculate("type")
async def _compute_shipping(self):
for record in self:
record.shipping_required = record.type == "Physical"

1. Always Use the @Model.calculate Decorator

Section titled “1. Always Use the @Model.calculate Decorator”

Every calculated field method must have the @Model.calculate decorator. Without it, the system won’t know to call your method:

# Good - decorator present
@Model.calculate("quantity", "unit_price")
async def compute_total(self):
for record in self:
record.total = record.quantity * record.unit_price
# Bad - missing decorator (raises error at startup!)
async def compute_total(self):
for record in self:
record.total = record.quantity * record.unit_price

For fields with no dependencies (computed from external sources), use empty parentheses:

@Model.calculate()
async def compute_is_current(self):
Website = get_model("Website")
for record in self:
website = await Website.filter(theme__id__eq=record.id).first()
record.is_current = website is not None

2. Iterate the recordset and assign every field on every record

Section titled “2. Iterate the recordset and assign every field on every record”

See the recordset rule above. Loop for record in self:, write record.<field>, and cover every branch with an explicit value. Assigning on the recordset itself (self.<field> = ...) raises CalculateAssignmentError; leaving a declared field unassigned on some record is not reported, which is why every branch needs an explicit value.

# Good
@Model.calculate("quantity", "unit_price", "discount")
async def _compute_total(self):
for record in self:
record.total = (record.quantity * record.unit_price) - record.discount
# Bad - missing dependency
@Model.calculate("quantity", "unit_price") # Missing "discount"!
async def _compute_total(self):
for record in self:
record.total = (record.quantity * record.unit_price) - record.discount
# Good - fetch_related batches across the whole recordset
@Model.calculate("lines")
async def _compute_line_count(self):
await self.fetch_related("lines")
for record in self:
record.line_count = len(record.lines or [])
# Better - the database counts it, and no child rows are fetched at all
line_count = Integer(aggregate=Count("lines"), store=False)
# Bad - one query per row on screen. Fails `check --only computes`.
@Model.calculate("lines")
async def _compute_line_count(self):
for record in self:
record.line_count = await Line.filter(order=record).count()

See One query per PAGE, never per record for the three sanctioned shapes.

# Good - not stored, just for display
display_name = Char(calculate="get_display_name", store=False)
# Consider storing if you need to filter/sort
searchable_name = Char(calculate="get_searchable_name", store=True, index=True)
@Model.calculate("quantity", "unit_price")
async def _compute_total(self):
for record in self:
qty = record.quantity or 0
price = record.unit_price or 0
record.total = qty * price