Skip to content
Talk to our solutions team

Computed fields

A computed field is derived rather than supplied. The engine evaluates an expression and either returns the result on read or stores it in a real column.

A field is computed when it declares computed:, script:, or a compute: block. The expression is evaluated by the script runtime — it is not pushed into SQL, so SQL syntax in a computed expression does not evaluate.

compute-strategyEvaluatedPersisted
virtualon read, after rows are scannedno
materializedon create and update, before the statement runsyes, into a real column

Any token other than virtual — including a typo such as virtaul — resolves to materialized, and so does omitting the key on a field declared computed through computed: or script:. The one exception: a field whose only compute declaration is a nested compute: block with neither compute-strategy: nor triggers: is virtual.

fields:
- name: takehome
type: int
computed: "basic + bonus - (professionaltax + incometax)"
compute-strategy: materialized
- name: display_name
type: string
nullable: true
compute:
language: expr
expression: "first_name + ' ' + last_name"
compute-strategy: virtual

Bindings available to the expression are row (the row or payload as a map), entity (the entity name), tenant and user_id. The materialized path additionally promotes every payload key to the top level, so qty * unit_price resolves directly.

language: accepts expr (the default), cel, js, javascript, lua, starlark, go and wasm. The body goes in expression: for a one-liner or script: for a full program; function: names the entry point for the module languages.

The data.* service namespace — data.query, data.count, data.aggregate and the rest — is injected only for js / javascript, lua, starlark and go. An expr, cel or wasm body cannot reach it.

Two further limits are worth knowing before you rely on computed values:

  • The engine never emits GENERATED ALWAYS AS … STORED. Derived values are the engine’s responsibility, so anything writing to the table with direct SQL bypasses computation.
  • An expression that fails to evaluate does not fail the request. The error is recorded in the response metadata under computed:error:<field> or materialized:error:<field> and the field is left as-is. evaluation time.
LanguageReaches data.*Use it for
expr (default)noArithmetic, concatenation, conditionals over the current row
celnoThe same, when you already write CEL elsewhere
js / javascriptyesAnything needing other rows, loops, or real string work
lua, starlark, goyesSame capability as JavaScript; pick on team familiarity
wasmnoPrecompiled modules

The line that matters: expr and cel see only the row in front of them. The moment a field depends on another entity, it has to be one of the scripting languages, and it has to be materialized with triggers.

expr is the default and the right choice for most computed fields. No language key needed.

Line total on an invoice line:

- name: line_total
type: double
computed: "qty * unit_price"

A total with tax, rounded to whole currency units:

- name: gross_total
type: double
computed: "round((qty * unit_price) * (1 + tax_rate), 2)"

Full name, tolerating a missing middle name:

- name: full_name
type: string
nullable: true
compute-strategy: virtual
computed: "first_name + (middle_name != nil ? ' ' + middle_name : '') + ' ' + last_name"

Take-home pay:

- name: takehome
type: double
computed: "basic + bonus - (professional_tax + income_tax + provident_fund)"

A margin percentage, guarding the divide:

- name: margin_pct
type: double
nullable: true
computed: "revenue > 0 ? ((revenue - cost) / revenue) * 100 : 0"

That guard is not decoration. An expression that fails does not fail the request — it records the error and leaves the field alone — so a divide-by-zero produces a silently absent value, not a loud failure. Guard the arithmetic rather than relying on the error surfacing.

A display label built from parts:

- name: display_ref
type: string
nullable: true
compute-strategy: virtual
computed: "'INV-' + string(year) + '-' + string(sequence)"

CEL suits a boolean or a small classification, and reads well when the condition is the point.

Whether an order qualifies for free shipping:

- name: free_shipping
type: bool
compute:
language: cel
expression: "row.subtotal >= 5000.0 && row.country == 'IN'"

A risk band:

- name: risk_band
type: string
nullable: true
compute-strategy: virtual
compute:
language: cel
expression: >
row.credit_score >= 750 ? 'low' :
(row.credit_score >= 600 ? 'medium' : 'high')

Overdue, from a date already on the row:

- name: is_overdue
type: bool
compute:
language: cel
expression: "row.due_date < row.as_of_date && row.status != 'paid'"

Note the shape: CEL reads through row., while a materialized expr body can name payload keys directly. Both bindings exist in both — row is always available.

Examples — other entities, with JavaScript

Section titled “Examples — other entities, with JavaScript”

Once a field depends on rows the current entity does not hold, you need a scripting language, a materialized strategy, and triggers so the stored value is refreshed when the source changes.

How many completed orders a customer has:

- name: completed_orders
type: int
nullable: true
computed: true
compute-strategy: materialized
compute:
language: javascript
script: |
return data.count("orders", {
customer_id: row.id,
status: "completed"
});
triggers:
- entity: orders
on: [create, update, delete]
affected_via: customer_id
when_columns: [customer_id, status]

when_columns is what keeps this cheap: an order whose shipping address changes does not enqueue a refresh, because neither listed column moved.

Lifetime value, summing a related entity:

- name: lifetime_value
type: double
nullable: true
computed: true
compute-strategy: materialized
compute:
language: javascript
script: |
const orders = data.query("orders", {
customer_id: row.id,
status: "completed"
});
return orders.reduce((sum, o) => sum + (o.total || 0), 0);
triggers:
- entity: orders
on: [create, update, delete]
affected_via: customer_id
when:
language: expr
expression: "row.status == 'completed'"

The when predicate is a second gate on top of on: a draft order never enqueues a refresh at all, so the queue only carries work that can change the answer.

A denormalised label from a parent, kept current:

- name: customer_name
type: string
nullable: true
computed: true
compute-strategy: materialized
compute:
language: javascript
script: |
const c = data.find_by_id("customers", row.customer_id);
return c ? c.name : null;
triggers:
- entity: customers
on: [update]
affected_via: id
when_columns: [name]

This is the classic reason to reach for materialization: the join is paid once on write instead of on every read, and the trigger keeps it honest when the customer is renamed.

A stock position across warehouses:

- name: total_on_hand
type: int
nullable: true
computed: true
compute-strategy: materialized
compute:
language: javascript
script: |
const rows = data.query("inventory", { sku: row.sku });
return rows.reduce((n, r) => n + (r.on_hand || 0), 0);
triggers:
- entity: inventory
on: [create, update, delete]
affected_via: sku
when_columns: [sku, on_hand]

Aggregation without pulling rows:

- name: avg_rating
type: double
nullable: true
computed: true
compute-strategy: materialized
compute:
language: javascript
script: |
const res = data.aggregate("reviews", {
filter: { product_id: row.id, published: true },
avg: "rating"
});
return res ? res.avg : null;
triggers:
- entity: reviews
on: [create, update, delete]
affected_via: product_id
when_columns: [product_id, rating, published]

Prefer data.aggregate to querying and summing in the script when you only need the number — it does the work in the database rather than moving every row into the runtime.

AskAnswer
Does it depend only on this row, and is it cheap?virtual — nothing to keep in sync
Is it read far more often than written?materialized
Does it depend on another entity?materialized, with triggers. There is no other option
Do you need to filter or sort by it in a query?materialized — a virtual field has no value in the column to filter on

That last row is the one people hit late. A virtual field is computed after rows are scanned, so the database cannot use it in a WHERE or an ORDER BY.

A materialized field can declare the source entities whose mutations invalidate it. Same entity computation needs no triggers.

KeyMeaning
entitySource entity. Required.
oncreate, update, delete. Empty matches all three.
affected_viaSingle source column selecting the target row.
affected_via_colsComposite key columns into the target row.
affected_via_exprScript expression deriving the target key.
when_columnsUpdate-only: skip the refresh when none of these columns changed.
whenScript predicate gate. Empty always enqueues.

Exactly one of affected_via, affected_via_cols and affected_via_expr may be set; more than one is a load error, as is a trigger naming an unknown entity or sitting on a field that is not materialized.

fields:
- name: order_count
type: int
nullable: true
computed: true
compute-strategy: materialized
compute:
language: javascript
script: |
return data.count("orders", { customer_id: row.id, status: "completed" });
triggers:
- entity: orders
on: [create, update, delete]
affected_via: customer_id
when_columns: [customer_id, status]
when:
language: expr
expression: "row.status == 'completed'"

The trigger graph is checked when the schema loads. Two things happen there, and neither is a runtime concern:

A cyclic graph is refused. If orders triggers a refresh on customer.order_total, and customers triggers one back on orders.customer_tier, the schema fails to load and the error names the full cycle path. This is a load-time refusal rather than a runtime guard because a cycle has no correct behaviour to fall back to — it would refresh forever.

A missing trigger is warned about. If a compute script appears to reference an entity that is not in its own triggers: list, the loader says so:

compute:
language: javascript
script: |
return data.count("orders", { customer_id: row.id });
triggers:
- entity: invoices # orders is referenced but not triggered
on: [create]
affected_via: customer_id

That is the “I forgot to add the trigger” case, and it is worth a warning rather than an error because the reference may be deliberate. Left alone, the field simply goes stale — no error, no failed request, just a number that stops moving. That is the hardest kind of bug to notice, which is why the loader looks for it.