Computed fields in Odoo are deceptively simple to declare and easy to misuse. The compute= parameter accepts a method name; store=True writes the result to the database column; depends() lists the fields that trigger recomputation. Three lines of code. But the consequences of the wrong combination range from stale data that silently lies to queries that scan millions of rows.
The fundamental split#
A computed field without store=True is a Python property with ORM sugar. Odoo calls the compute method every time the field is accessed. The value never touches the database.
class SaleOrder(models.Model):
_inherit = 'sale.order'
margin_pct = fields.Float(compute='_compute_margin_pct')
@api.depends('margin', 'amount_untaxed')
def _compute_margin_pct(self):
for order in self:
if order.amount_untaxed:
order.margin_pct = order.margin / order.amount_untaxed * 100
else:
order.margin_pct = 0.0With store=True, Odoo adds a real database column and recomputes the value whenever the depends-tracked fields change, writing the result via SQL UPDATE.
margin_pct = fields.Float(compute='_compute_margin_pct', store=True)When to store#
Store the field when you need to:
1. Filter or group in domain expressions
# This works only if margin_pct is stored
orders = self.env['sale.order'].search([('margin_pct', '>', 20)])Non-stored computed fields cannot appear in search() domains. Attempting it raises ValueError: Invalid field. You can work around it by overriding _search on the field, but that means writing the SQL yourself - at which point you should ask whether storing is simpler.
2. Appear in group_by or pivot views
Report views and pivot tables run SQL GROUP BY. Non-stored fields cannot be grouped.
3. Avoid per-record Python calls on large recordsets
When a list view loads 200 records and each row shows a computed field, Odoo calls the compute method once per record (or in batches with the full recordset if you iterate properly). A stored field is a single SQL column read.
When not to store#
Do not store when:
- The computation depends on something Odoo cannot track - the current time, an external API call, a field on a non-Odoo system.
- The depends chain would be so wide that almost any write would trigger recomputation.
- The field is only shown in form view for a single record and the query cost is negligible.
- You want real-time freshness and cannot tolerate even the brief lag between a write and the async recomputation trigger.
The depends decorator in detail#
@api.depends takes dotted-path field names. Odoo tracks invalidation through these paths.
@api.depends('order_line.product_id.list_price', 'order_line.qty')
def _compute_list_total(self):
for order in self:
order.list_total = sum(
line.product_id.list_price * line.qty
for line in order.order_line
)When list_price changes on any product referenced by an order line, Odoo marks the corresponding sale.order record for recomputation. This traverses the Many2one chain automatically.
Common mistake: Missing a field in depends causes the stored value to go stale without error. Odoo will not warn you. The column holds yesterday's computation while a related field changed.
Debug stale stored fields:
# Force recomputation of all records
SaleOrder = self.env['sale.order']
records = SaleOrder.search([])
records._compute_list_total()Or trigger from the shell:
# In Odoo shell
env['sale.order'].search([])._compute_list_total()Related fields as a special case#
related= is syntactic sugar for a read-through computed field:
partner_country = fields.Many2one(
'res.country',
related='partner_id.country_id',
store=True,
)store=True on a related field denormalizes the value. Useful for reporting; dangerous if the related field changes frequently and you need real-time accuracy.
Async vs. synchronous recomputation#
When a stored computed field is invalidated, Odoo does not immediately recompute it. The ORM marks affected records and recomputes lazily the next time the field is read, or via the _recompute_todo mechanism.
In practice, within a single transaction: write triggers the recomputation before the commit. Across transactions (e.g., a cron that writes in one transaction, then a second reads): the second read will find the stored value from before the recomputation unless the first transaction flushed.
If you need the fresh value immediately after a write in the same transaction:
order.write({'amount_untaxed': new_total})
order.flush_recordset(['margin_pct']) # Forces recompute before read
print(order.margin_pct) # Now freshPerformance measurement#
Compare stored vs. non-stored with a real dataset. Set up logging:
import time
import logging
_logger = logging.getLogger(__name__)
# Time a search on a stored field
t0 = time.time()
orders = env['sale.order'].search([('margin_pct', '>', 20)])
_logger.info('stored search: %.3fs, %d records', time.time() - t0, len(orders))For non-stored fields, measure how long a list view load takes in the browser network tab. A 300 ms load time on a 100-record list, where each record triggers a Python compute call, usually justifies adding store=True.
Rule of thumb#
If you build it for a report, group by, or list domain: store it. If it is only used in a form view or in Python logic with no SQL involvement: skip store=True. When in doubt, start without storage, profile, and add storage only when you have a measured problem.

