Computed fields are among the first things developers reach for when extending Odoo. They look simple: add a @api.depends, write a compute method, done. But the tradeoffs between stored and non-stored computation, the subtle rules around dependency declaration, and the edge cases around inverse methods create a category of bugs that consistently shows up in production codebases - often weeks after the code shipped.
This guide covers how computed fields actually work in the Odoo ORM, where they go wrong, and the patterns that avoid common mistakes.
The two kinds of computed fields#
Every computed field in Odoo is either stored or non-stored. The choice determines when the computation runs and where the result lives.
Non-stored (default)
class SaleOrder(models.Model):
_inherit = 'sale.order'
margin_pct = fields.Float(compute='_compute_margin_pct')
@api.depends('amount_untaxed', 'amount_cost')
def _compute_margin_pct(self):
for order in self:
if order.amount_untaxed:
order.margin_pct = (order.amount_untaxed - order.amount_cost) / order.amount_untaxed
else:
order.margin_pct = 0.0Non-stored fields are computed on every read. Odoo never writes them to the database. When a view, report, or Python code reads margin_pct, Odoo calls _compute_margin_pct on the recordset, evaluates the formula, and returns the result in memory.
Advantages: always up to date, no migration needed, no storage cost.
Disadvantage: cannot be used in domain filters on search_read or search calls. If you try self.env['sale.order'].search([('margin_pct', '>', 0.2)]), Odoo raises ValueError: Invalid field margin_pct in leaf.
Stored
margin_pct = fields.Float(compute='_compute_margin_pct', store=True)With store=True, Odoo writes the computed value to the database column after each recomputation. The column behaves like a regular field for read queries - you can use it in domain filters, sort by it, and index it.
Disadvantage: the value in the database can become stale if a dependency changes through a path Odoo doesn't track (more on this below).
How @api.depends works#
@api.depends tells the ORM which fields, when written, should trigger a recomputation of this computed field.
@api.depends('order_line.price_unit', 'order_line.product_uom_qty', 'order_line.discount')
def _compute_amount_total(self):
...Odoo interprets dot-notation paths. order_line.price_unit means: "when price_unit on any order_line record linked to this order changes, recompute." Odoo resolves these paths using the field's relational metadata.
Important: the dependency declaration is static and path-based. Odoo does not inspect the Python body of the compute method to infer dependencies automatically. If you access a field in the compute method without declaring it in @api.depends, the computation will not retrigger when that field changes.
Multi-level paths#
Odoo supports multi-hop dependency paths:
@api.depends('partner_id.country_id.code')
def _compute_is_eu_customer(self):
for rec in self:
rec.is_eu_customer = rec.partner_id.country_id.code in EU_COUNTRY_CODESHere, changing the country_id on the partner, or changing the code on that country, will trigger a recomputation of is_eu_customer on all records that reference that partner.
This is powerful but expensive at scale. If a country record changes its code, Odoo will queue recomputation for every order linked to every partner in that country.
Stored computed fields: the recomputation queue#
When a dependency changes, Odoo does not recompute stored fields synchronously in the same transaction by default. Instead, it writes the dependent record IDs to a recomputation queue (the ir.rule-equivalent internal mechanism). The recomputation happens:
- In the same transaction, if the ORM determines it is needed before committing (e.g., the value is read in the same request).
- As a deferred background job (in Odoo 16+, via the
_recompute_todomechanism and automated actions).
The practical consequence: after a bulk write that changes a dependency, the stored computed field may not reflect the new value immediately if you read it in a separate request or a different session.
Anti-pattern: relying on stale reads
# This can return the pre-computation value if the recompute hasn't run yet
orders = self.env['sale.order'].search([('state', '=', 'sale')])
for order in orders:
order.partner_id.write({'country_id': new_country.id})
# Immediately reading margin_pct here may still show the old value
print(orders[0].margin_pct) # Possibly staleIf you need the value immediately after a dependency change, call self.env['sale.order']._recompute_todo() or invalidate the cache with orders.invalidate_recordset() before reading.
The silent failure: missing dependencies#
The most common computed field bug in production: a dependency that should be declared but is not.
@api.depends('product_id') # BUG: missing 'product_id.list_price'
def _compute_suggested_price(self):
for line in self:
line.suggested_price = line.product_id.list_price * 1.1With this declaration, _compute_suggested_price will retrigger when product_id on the order line changes. But if someone changes list_price on the product record, the suggested price will not recompute.
This works correctly during development because list_price rarely changes in test data. It breaks in production months later when a product manager updates pricing and order lines show stale suggested prices.
Fix:
@api.depends('product_id', 'product_id.list_price')
def _compute_suggested_price(self):
...Or equivalently:
@api.depends('product_id.list_price')
def _compute_suggested_price(self):
...Odoo resolves the product_id hop automatically from the dot path.
Inverse methods#
A stored computed field can optionally declare an inverse method, making it writable from the UI even though it is computed.
class ProductTemplate(models.Model):
_inherit = 'product.template'
margin_target = fields.Float(
compute='_compute_margin_target',
inverse='_inverse_margin_target',
store=True,
)
@api.depends('list_price', 'standard_price')
def _compute_margin_target(self):
for product in self:
if product.list_price:
product.margin_target = (product.list_price - product.standard_price) / product.list_price
else:
product.margin_target = 0.0
def _inverse_margin_target(self):
for product in self:
if product.margin_target < 1.0:
product.list_price = product.standard_price / (1.0 - product.margin_target)When a user types a value into margin_target in the view, Odoo calls _inverse_margin_target, which back-calculates and sets list_price. The next time _compute_margin_target runs, the stored value will match what was entered (within floating-point precision).
Trap: If the inverse method writes a field that is itself a dependency of this computed field, you can create a recomputation loop. Odoo detects direct loops and raises, but circular paths through intermediary fields can cause unexpected recomputations.
Related fields: a shortcut for simple cases#
For the common case of exposing a field from a Many2one relation, fields.related is cleaner than a manual compute:
# Instead of:
@api.depends('partner_id.country_id')
def _compute_partner_country(self):
for rec in self:
rec.partner_country_id = rec.partner_id.country_id
partner_country_id = fields.Many2one('res.country', compute='_compute_partner_country')
# Use:
partner_country_id = fields.Many2one(
'res.country',
related='partner_id.country_id',
store=False,
)fields.related generates the @api.depends declaration and compute method automatically. It supports store=True and read-only or writable modes. Use it for single-path projections; write a manual compute for anything that involves conditional logic or aggregation.
Performance: when stored computation becomes expensive#
Stored computed fields create writes on every recomputation. If a field depends on a One2many or Many2many with many records, every line change recomputes the header field.
# This recomputes on every order line change
@api.depends('order_line.price_subtotal')
def _compute_amount_untaxed(self):
for order in self:
order.amount_untaxed = sum(order.order_line.mapped('price_subtotal'))This is fine for a typical order with 5–20 lines. It becomes a problem when orders have 500+ lines (large manufacturing BOMs, subscription orders with many components) or when batch imports write thousands of lines in a loop.
Optimization: avoid recomputation in batch import paths
# In a batch import wizard, suspend recomputation temporarily
with self.env.norecompute():
for vals in batch:
self.env['sale.order.line'].create(vals)
# Trigger a single recompute pass after the loop
self.env['sale.order']._recompute_todo()env.norecompute() is an Odoo context manager (available from Odoo 14+) that defers all queued recomputations until you exit the context. Use it for bulk-create or bulk-write operations where each intermediate state doesn't need to be consistent.
Non-stored fields and search methods#
If you need a non-stored computed field to be searchable via domain, you must implement a search method:
class ProjectTask(models.Model):
_inherit = 'project.task'
is_overdue = fields.Boolean(compute='_compute_is_overdue', search='_search_is_overdue')
@api.depends('date_deadline')
def _compute_is_overdue(self):
today = fields.Date.today()
for task in self:
task.is_overdue = bool(task.date_deadline and task.date_deadline < today)
def _search_is_overdue(self, operator, value):
today = fields.Date.today()
if operator == '=' and value:
return [('date_deadline', '<', today), ('date_deadline', '!=', False)]
elif operator == '=' and not value:
return ['|', ('date_deadline', '>=', today), ('date_deadline', '=', False)]
raise NotImplementedError(f'Unsupported operator: {operator}')The _search_is_overdue method receives the operator and value from the domain clause and must return a valid domain that the ORM can use in a SQL WHERE clause. This is what allows self.env['project.task'].search([('is_overdue', '=', True)]) to work without a stored column.
Checklist: before shipping a computed field#
- Are all accessed fields declared in
@api.depends? Check everyrec.in the method body.. - If the field is stored, have you verified recomputation behavior after a bulk write on a dependency?
- If the field uses dot-path dependencies on Many2many or One2many, have you estimated the worst-case recomputation cost?
- If you have an inverse method, does it avoid writing to any of the field's own dependencies?
- If the field is non-stored but needs to be filterable, is a
searchmethod implemented? - Does the compute method handle
False/Nonedependencies safely (e.g.,partner_idbeing empty)?
ERPeek lets you ask questions like "which computed fields in our custom modules have missing @api.depends declarations?" or "show me all stored computed fields that depend on a One2many with more than 3 levels of nesting." If you are auditing an inherited Odoo codebase before an upgrade, the contact page has a trial path.

