DEV Community

Cover image for Circular recomputation and rounding drift in Odoo
Borovlev Artem
Borovlev Artem

Posted on

Circular recomputation and rounding drift in Odoo

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. The frontend sends the changed value to the server.
  2. Odoo creates a temporary record snapshot.
  3. The modified field is marked as changed.
  4. All related @api.onchange and @api.depends logic is executed.
  5. 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 → updates purchase_price_usd
  • purchase_price_usd → updates purchase_price

the mechanism becomes symmetrical.

When the user changes purchase_price:

  • purchase_price_usd is 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
Enter fullscreen mode Exit fullscreen mode

But once rounding is applied, this is no longer true.

With rounding to two decimal places:

round(round(x / rate, 2) * rate, 2)  x
Enter fullscreen mode Exit fullscreen mode

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 onchange and 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)
Enter fullscreen mode Exit fullscreen mode

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 = ...
Enter fullscreen mode Exit fullscreen mode

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)