The Recomputation Loop Problem
Unlike a simple RecursionError that crashes immediately, a recomputation loop is subtler and more dangerous. The computed field keeps recalculating without hitting Python's recursion limit — instead, it causes extremely slow saves, high CPU usage, or hangs that eventually time out. This guide focuses on these silent infinite loops rather than the hard crash scenario.
Symptoms of a Recomputation Loop
- Saving a record takes 30+ seconds or times out
- CPU spikes to 100% when editing specific fields
- Database shows hundreds of UPDATE queries for the same table
- Odoo worker gets killed for exceeding time limits
- No error in logs — just slow or stuck behavior
What the Slow Query Looks Like
# In PostgreSQL logs with log_min_duration_statement = 1000:
LOG: duration: 45123.456 ms statement: UPDATE sale_order SET
total_margin = ..., margin_percent = ..., write_date = ...
WHERE id IN (1, 2, 3, ... 500)
# In Odoo logs:
WARNING odoo.models: Recomputation of field 'sale.order.total_margin'
triggered recomputation of 'sale.order.margin_percent'
which triggered recomputation of 'sale.order.total_margin'
(cycle detected, stopping after 100 iterations)Cause 1: Write Inside Compute Method
The most dangerous pattern. A compute method that writes to fields on the same record triggers recomputation of dependent fields, which may trigger the original compute again.
# BAD: write() inside compute triggers recomputation loop
class SaleOrder(models.Model):
_inherit = 'sale.order'
risk_level = fields.Selection(
[('low', 'Low'), ('medium', 'Medium'), ('high', 'High')],
compute='_compute_risk', store=True
)
@api.depends('amount_total')
def _compute_risk(self):
for rec in self:
level = 'high' if rec.amount_total > 10000 else 'low'
rec.risk_level = level
# BAD — this write triggers field recomputation!
rec.write({'note': f'Risk updated to {level}'})
# FIX: Never call write() inside a compute method
# Set all needed fields directly:
@api.depends('amount_total')
def _compute_risk(self):
for rec in self:
rec.risk_level = 'high' if rec.amount_total > 10000 else 'low'Cause 2: Inverse Method Triggering Compute
Inverse methods allow writing to computed fields. But if the inverse method modifies a field that the compute method depends on, you get a loop.
# BAD: inverse writes to a field that compute depends on
class Product(models.Model):
_inherit = 'product.template'
margin_amount = fields.Float()
sale_price = fields.Float(
compute='_compute_sale_price',
inverse='_inverse_sale_price',
store=True
)
@api.depends('standard_price', 'margin_amount')
def _compute_sale_price(self):
for rec in self:
rec.sale_price = rec.standard_price + rec.margin_amount
def _inverse_sale_price(self):
for rec in self:
# BAD: writing margin_amount triggers _compute_sale_price again
rec.margin_amount = rec.sale_price - rec.standard_price
# FIX: Use a context flag to break the cycle
def _inverse_sale_price(self):
for rec in self:
rec.with_context(skip_price_compute=True).margin_amount = (
rec.sale_price - rec.standard_price
)
@api.depends('standard_price', 'margin_amount')
def _compute_sale_price(self):
if self.env.context.get('skip_price_compute'):
return
for rec in self:
rec.sale_price = rec.standard_price + rec.margin_amountCause 3: Onchange Triggering Related Compute
An onchange method modifies a field that a stored computed field depends on, causing recomputation during form editing.
# BAD: onchange modifies a dependency of a stored computed field
@api.onchange('partner_id')
def _onchange_partner(self):
# This triggers recomputation of all fields that depend on pricelist_id
self.pricelist_id = self.partner_id.property_product_pricelist
# Which triggers price recomputation for all order lines
# Which triggers total recomputation
# Which may trigger other computations...
# FIX: Be aware of the chain reaction. If performance is an issue:
# 1. Use @api.depends instead of @api.onchange where possible
# 2. Batch the changes instead of setting fields one by one
# 3. Minimize stored computed fields that depend on frequently-changed fieldsCause 4: Cross-Model Dependency Chains
Field A on Model X depends on field B on Model Y, which depends on field C on Model X. The cycle spans multiple models, making it hard to spot.
# Model: sale.order
total_weight = fields.Float(compute='_compute_weight', store=True)
@api.depends('order_line.product_weight')
def _compute_weight(self):
...
# Model: sale.order.line
product_weight = fields.Float(compute='_compute_product_weight', store=True)
@api.depends('order_id.warehouse_id') # depends back on the order!
def _compute_product_weight(self):
# Different warehouses have different packaging weights
...
# FIX: Break the cross-model cycle
# Make one of the fields non-stored, or
# remove the back-reference dependencyHow to Detect Loops
# 1. Enable SQL logging to see repeated queries:
./odoo-bin --log-handler=odoo.sql_db:DEBUG 2>&1 | grep UPDATE | head -50
# 2. Use Python profiling to find the hot function:
import cProfile
cProfile.run('record.write({"field": value})')
# 3. Check field dependencies in shell:
env['sale.order']._field_computed # Shows which compute method handles each field
for name, field in env['sale.order']._fields.items():
if hasattr(field, 'depends') and field.depends:
print(f'{name}: {field.depends}')
# 4. Look for write() calls in compute methods:
grep -rn 'def _compute' my_module/ -A 10 | grep '\.write('Prevention Rules
- Never call
write(),create(), orunlink()inside a compute method - Keep dependency chains short — ideally one level deep
- Draw the dependency graph before adding stored computed fields
- Use non-stored computed fields when you do not need search/sort
- Test save performance with realistic data volumes, not just one record
- Monitor SQL queries during saves to catch loops early