The Real Problem
In ERP systems, it’s common to have two fields that represent the same value in different units — for example, a purchase price in company currency and the same price in USD.
Both fields are editable. Both must stay synchronized.
For simplicity, let’s assume the conversion logic looks like this:
purchase_price_usd = round(purchase_price / rate, 2)
purchase_price = round(purchase_price_usd * rate, 2)
The formulas here are intentionally simplified.
In real systems you would use proper currency utilities, precision handling, and framework helpers.
The goal of this example is to demonstrate the recomputation problem, not currency implementation details.
Now consider a basic case:
- rate = 3
- user enters
purchase_price = 10
The system calculates:
purchase_price_usd = round(10 / 3, 2) = 3.33
purchase_price = round(3.33 * 3, 2) = 9.99
Now the original value changes from 10 to 9.99.
Mathematically, this is correct.
From a user perspective, it is not.
The user entered 10. The system should not silently rewrite it.
This is not a floating-point bug.
This is not a rounding bug.
This is a consequence of combining:
- bidirectional field derivation
- rounding
- automatic recomputation
And this is where circular recomputation begins to break user intent.
Why It Happens: The Onchange Loop
In Odoo, editable forms rely heavily on onchange.
When a user modifies a field in the UI:
- The frontend sends the changed value to the server.
- Odoo creates a temporary record snapshot.
- The modified field is marked as changed.
- All related
@api.onchangeand@api.dependslogic is executed. - If any additional fields change during that process, Odoo runs another pass.
This continues until no more fields are considered “changed”.
This behavior is correct and intentional. It ensures consistency across dependent fields.
However, in a bidirectional setup like:
-
purchase_price→ updatespurchase_price_usd -
purchase_price_usd→ updatespurchase_price
the mechanism becomes symmetrical.
When the user changes purchase_price:
-
purchase_price_usdis recomputed - that recomputation modifies
purchase_price - the system detects a change
- another pass begins
Even if the difference is only a rounding adjustment, it is still considered a change.
The framework does not know which field represents the original user intent.
It only sees that values differ - and tries to synchronize them.
This is how circular recomputation emerges.
Rounding as the Hidden Amplifier
Circular recomputation alone is not always visible.
The real problem becomes obvious when rounding is involved.
In bidirectional transformations, we implicitly assume that:
f⁻¹(f(x)) = x
But once rounding is applied, this is no longer true.
With rounding to two decimal places:
round(round(x / rate, 2) * rate, 2) ≠ x
Even if the difference is only 0.01, the system detects it as a real change.
From the framework’s perspective:
- the value is different
- therefore it must be synchronized
From the user’s perspective:
- the value they entered was rewritten
The rounding does not create the loop.
It makes the loop observable.
Without rounding, tiny floating-point differences might still exist, but they often remain invisible at the UI level.
With rounding, the drift becomes explicit - and user intent gets overridden.
This is where mathematical correctness collides with practical ERP behavior.
Why the Usual Approaches Don’t Solve It
It is entirely possible to implement bidirectional synchronization using @api.depends and inverse.
Many Odoo modules use context flags inside inverse methods to prevent recursive writes. At the database level, this approach works reliably.
The real limitation appears at the UI level.
compute + inverse ensures consistency when the record is written. But it does not guarantee immediate synchronization while the user is editing a form.
In the form view, updates happen through onchange.
When a field is modified:
- Odoo builds a temporary snapshot of the record
- marks the changed fields
- executes related
onchangeand compute logic - checks which fields were modified
- runs additional passes if necessary
All of this happens inside a single onchange execution cycle.
There is no new request.
There is no fresh evaluation context.
The same record snapshot is iteratively processed until no further changes are detected.
This is important.
If two fields update each other, and rounding produces even a small difference, the framework sees it as a real modification. The loop continues.
So while compute + inverse can keep values consistent at write time, they do not solve the UI-level circular recomputation problem.
To preserve user intent during form editing, direction must be controlled inside the onchange cycle itself.
Controlling Direction Inside the Onchange Cycle
The core insight is simple:
At any given moment, only one field represents user intent.
When a user edits purchase_price, that value must be treated as authoritative.
The synchronized field (purchase_price_usd) should be updated - but the original field must not be touched again during the same cycle.
And vice versa.
The problem is that the onchange loop treats both fields symmetrically. It only sees value differences and tries to keep them aligned.
So we need to break that symmetry.
The solution is not to disable recomputation.
The solution is to control direction.
In practice, this means:
- Detect which field triggered the onchange.
- Set a context flag indicating the direction of synchronization.
- Prevent the reverse update during the same cycle.
Example (simplified):
def onchange(self, values, field_names, fields_spec):
ctx = {}
if "purchase_price" in field_names:
ctx["skip_purchase_price_recompute"] = True
if "purchase_price_usd" in field_names:
ctx["skip_purchase_price_usd_recompute"] = True
self = self.with_context(**ctx)
return super().onchange(values, field_names, fields_spec)
And in the corresponding compute methods:
@api.depends("purchase_price")
def _compute_purchase_price_usd(self):
if self.env.context.get("skip_purchase_price_usd_recompute"):
return
for record in self:
record.purchase_price_usd = ...
This does not change the mathematical model. It changes the execution model.
Instead of letting both fields fight for dominance, we explicitly define which side is the source of truth for this specific user action.
The rounding still exists.
The formulas remain symmetrical.
But the recomputation is no longer circular.
User intent is preserved.
Conclusion
Bidirectional field synchronization combined with rounding inevitably breaks mathematical reversibility.
The framework is not wrong.
The formulas are not wrong.
The issue arises from treating both fields as equally authoritative during UI recomputation.
In Odoo’s onchange cycle, any detected difference triggers another evaluation pass. With rounding involved, even minimal deviations are treated as real changes.
The only reliable way to preserve user intent is to control direction explicitly.
At any given moment:
- one field must be considered the source of truth
- the other must be derived
- and reverse recomputation must be suppressed within the same evaluation cycle.
This is not a workaround. It is an architectural constraint of bidirectional derived fields in UI-driven systems.
Top comments (0)