Beyond the Syntax: How onchange, depends and inverse Really Work in Odoo
#Python

Beyond the Syntax: How onchange, depends and inverse Really Work in Odoo

Backend Reporter
13 min read

A deep dive into the mechanics of Odoo's ORM decorators, revealing why the standard 'onchange is UI, depends is for save' explanation falls short and how these features actually interact under the hood.

The Problem Ask any Odoo developer to explain the difference between @api.depends and @api.onchange, and you'll get some version of the same answer: "onchange is for the UI - it fires when the user changes a field in the form. depends is for computed fields - it runs when you save." It's not wrong. But it's incomplete in ways that matter. And that incompleteness quietly causes bugs, unnecessary code, wasted debugging hours, and team arguments about why something "sometimes works and sometimes doesn't."

Here's what that incomplete understanding looks like in practice: a developer notices that a computed field isn't updating in the form view, so they add @api.onchange on top of @api.depends. It works. They ship it. Nobody questions it. Six months later someone else looks at that code and wonders: do we need both? what does each one actually do here?

Or a developer implements two fields that should stay in sync - change one, the other updates automatically. They set up compute and inverse. It works on save. But in the form view, it doesn't update until after saving. They're confused: isn't inverse supposed to handle this?

These aren't edge cases. They come up regularly, and they come up because the standard explanation - "onchange is UI, depends is on save" - doesn't tell you what's actually happening inside Odoo's ORM. This article does.

Note: This research is based on Odoo 17 source code. The core mechanics described here apply equally to Odoo 16 and 18 - the underlying architecture hasn't changed.

The Interview Answer (And Why It's Not Enough) The standard explanation maps roughly to this mental model:

User changes field in form ↓ @api.onchange fires ← "UI stuff" ↓ User clicks Save ↓ @api.depends fires ← "DB stuff"

This model isn't completely wrong - it just describes what happens, not how or why. And once you start doing anything beyond the basics, the "what" stops being enough.

Consider these questions that the standard answer can't address:

If @api.depends only fires on save, why does a computed field sometimes update in the form view before saving? If you have @api.depends and @api.onchange on the same method, are they doing the same thing? Different things? Is one redundant? Why does inverse work perfectly when you call write() in code, but nothing happens when the user edits the field in the form? Why does removing @api.onchange from a computed field sometimes break the UI - and sometimes change nothing at all?

To answer these, you need to understand what actually happens when a user changes a field value in an Odoo form. Not conceptually - mechanically. Let's go through it.

What Actually Happens When a User Changes a Field When a user modifies a field value in an Odoo form view, the browser sends an RPC call to the server - specifically to the onchange() method in web/models/models.py. What happens inside that method is more nuanced than it appears. Here's the high-level sequence:

  1. flush_all() - clear any pending computations from before
  2. record = self.new(values) - create a virtual record (no database ID)
  3. snapshot0 - capture the initial state of all fields
  4. _update_cache(changed_values) - apply the user's change to the virtual record
  5. modified(changed_fields) - register which fields need recomputation
  6. @api.onchange loop - the real action happens here (see below)
  7. snapshot1 - capture the final state of all fields
  8. diff(snapshot0, snapshot1) - return only what changed to the browser

First, an important detail: the record has no database ID. Odoo creates a virtual record - called a NewId record - to process the onchange. It exists only in memory. Nothing is written to the database at this point. This isn't a minor implementation detail - it's fundamental to understanding why certain things behave differently in onchange vs. write/create. We'll come back to this.

Now, step 5: what does modified() actually do? modified() is an internal ORM method that most developers never call directly and rarely think about. When Odoo calls record.modified(changed_fields), it doesn't recompute anything. It simply walks the dependency graph - the relationships declared through @api.depends - and registers which computed fields are now outdated. Those fields get added to an internal queue called tocompute. That's it. No computation. Just bookkeeping.

The real action is in step 6 - the onchange loop. After modified() runs, Odoo enters a loop that processes @api.onchange methods for each changed field. But the interesting part isn't the onchange methods themselves - it's what happens between iterations. After each onchange method runs, Odoo needs to figure out: did anything else change as a result?

To do that, it reads the current value of every field in the form spec and compares it to the snapshot. And here's the key: reading a field triggers get(). get() is Python's descriptor protocol - it's called whenever you access an attribute on an object. Odoo's field implementation hooks into this. Inside get(), before returning a value, Odoo checks: is this field in the tocompute queue? If yes - compute it now.

So the actual execution of your @api.depends method happens here, inside the loop, triggered by reading the field to check if it changed:

onchange method runs for field_x ↓ Odoo checks: what else changed? ↓ reads field_y value → get() → field_y is in tocompute? ↓ YES → your @api.depends method runs right here ↓ field_y now has new value → it changed → added to next loop iteration

This cascading behavior is what makes the onchange system reactive. A user changes one field, an onchange method updates a second field, @api.depends recomputes a third - and all of this happens in a single RPC call, before any data touches the database.

snapshot1 in step 7 is just the final read of all fields after the loop completes. By that point, everything that needed computing has already been computed.

So: @api.depends does not fire "on save" as a standalone event. The method runs when the field is read (lazy evaluation via get()), or when a flush occurs - which is what actually writes computed values to the database. During onchange, the read happens inside the loop. Outside of onchange, recomputation happens when the field is read or when a flush is called - for example at the end of a transaction.

The "on save" mental model collapses two separate things into one: the moment the compute method runs, and the moment the result reaches the database. They're not the same event.

Where inverse Fits In - And When It Doesn't inverse is Odoo's mechanism for making a computed field writable. When you define a computed field with an inverse method, you're telling Odoo: "if someone writes to this field directly, here's what to do with that value."

amount_currency = fields.Monetary( compute='_compute_amount_currency', inverse='_inverse_amount_currency', store=True, )

def _inverse_amount_currency(self): for line in self: # env.is_protected() checks whether the field is currently being # recomputed by the ORM - a safeguard to prevent circular updates # when inverse and compute methods interact with each other. if not self.env.is_protected(self._fields['balance'], line): line.balance = line.amount_currency / line.currency_rate

inverse has exactly two entry points in the ORM: write() and create(). That's it. There is no other place in the framework where inverse methods are called.

This means: when a user changes a field in the form view and the onchange RPC fires - inverse is never involved. onchange doesn't go through write(). It works directly with the record cache via _update_cache(). The entire onchange pipeline - applying changes, triggering dependencies, running handlers - happens without touching write() at all.

So if you're expecting an inverse method to fire because a user edited a field in the UI, that expectation is wrong by design. inverse is a persistence mechanism. It runs when data is being committed - not when a user is still filling out a form.

There's another layer to this. Remember that onchange works on a NewId record - a virtual record with no database ID. Even if you somehow called write() manually inside an onchange handler, write() filters records before calling inverse:

real_recs = self.filtered('id') # NewId records are excluded here

The filter exists because write() can be called in contexts where both real and virtual records are mixed. It's a safeguard, not a limitation.

The practical consequence: inverse handles the database side of a field. For anything that needs to happen in the UI before saving, you need a different tool - which is what the next section covers.

The Right Tools for the Right Job Now that we understand how each mechanism works, the choice between them becomes clearer.

@api.depends declares a computational relationship between fields. It tells Odoo: when these source fields change, mark this computed field as outdated. The actual recomputation happens later - when something reads the field. In the context of onchange, that means the field updates in the UI only if Odoo reads it during the onchange pipeline (which happens when the field is part of the form's field spec). But @api.depends itself is not a UI mechanism - it's a dependency declaration. Without something triggering a read, the computed value stays stale until the next database flush.

@api.onchange is an event handler that the framework calls automatically when a user changes a field in the form view. The key word is automatically - Odoo's onchange pipeline picks up these methods and runs them. You can call them manually from code too, and they'll work fine. But the framework won't call them for you outside of a UI context. Use @api.onchange for things that are inherently about the editing experience: showing warnings, setting default values, recalculating fields, or any other logic that should react to user input in the form.

inverse is a persistence mechanism. Use it when a computed field needs to be writable and changing it should propagate back to the source data - but only at save time, only for records with real database IDs.

A practical way to think about it:

Triggered by Updates

UI before save @api.depends read (lazily, after field is marked outdated) ✗ @api.onchange onchange RPC (user edits in form) ✓ inverse write / create ✗

Beyond the trigger: how these methods actually behave differently. Understanding when each method fires is only part of the picture. They also behave differently in terms of what they receive and what they can return.

When @api.onchange is triggered from the UI, self is always a recordset of exactly one record - the one the user is editing. In @api.depends, self can be a batch of multiple records, which is why you always iterate with for record in self. In onchange that loop still works fine, but it's good to know the guarantee is there.

More importantly: @api.depends methods don't return anything. They compute a value and assign it directly to the field. That's the entire contract. @api.onchange methods can optionally return a dictionary. In Odoo 17, the framework processes two keys from that return value:

return { "value": {"field_name": new_value, ...}, # assign field values "warning": { "title": "Something to note", "message": "Details here", "type": "dialog", # or "notification" } }

value lets you set other field values from inside the method - though assigning directly via self.field = value does the same thing and is more common. warning shows a dialog or notification to the user. Both keys are optional, and returning nothing at all is perfectly valid.

This return structure is unique to @api.onchange. There's no equivalent in @api.depends - computed fields communicate their result by assigning to self, not by returning.

On the double decorator question. You'll sometimes see code like this:

@api.depends('partner_id') @api.onchange('partner_id') def _compute_payment_term(self): for record in self: record.payment_term_id = record.partner_id.property_payment_term_id

This isn't always wrong - but it's worth asking: why are both decorators here? @api.depends already causes this method to run during onchange, via the tocompute → get() mechanism we described. Adding @api.onchange on top means the method runs twice during an onchange RPC: once triggered by @api.depends through the dependency system, and once explicitly by the onchange loop.

In most cases, one of them is redundant. The legitimate reason to have both is when you need @api.onchange specifically - to show a warning or handle UI-specific logic alongside a computed field. In that case, keep them as two separate methods: one decorated with @api.depends for the computation, one with @api.onchange for the UI side effects.

Technically you can combine them, and it will work - but a method that both computes a field value and returns a warning is doing two different things. That makes it harder to read, harder to override in a subclass, and harder to reason about. If you can't articulate why both decorators are needed on the same method, one of them probably shouldn't be there.

When things interact: a real example. The clearest illustration of all three mechanisms working (and conflicting) together is bidirectional computed fields - where two fields each depend on the other, and changing either one should update the other in both the UI and the database. This is exactly the problem I ran into and wrote about previously. The solution requires understanding that @api.onchange handles the UI reactivity, inverse handles the database sync, @api.depends keeps computed values current, and context flags are needed to prevent infinite recomputation loops - because each mechanism operates in a different scope and none of them alone is sufficient.

If you want to see these mechanics applied to a concrete problem with rounding drift on top, that writeup is here.

Conclusion The standard interview answer - "onchange is for the UI, depends is for save" - isn't wrong. But it describes symptoms, not causes. And when you're debugging unexpected behavior or designing a non-trivial field interaction, symptoms aren't enough. What actually matters is understanding the machinery:

@api.depends declares a dependency. The framework marks dependent fields as outdated when inputs change, and recomputes them lazily - when something reads the field. In the onchange pipeline that happens inside the loop, not at save time. @api.onchange is an event handler that the framework calls automatically from the UI. It receives a single record, can assign field values, and can return a warning. Nothing more is processed. inverse is called exclusively from write() and create(). It never runs during onchange, because onchange doesn't go through those methods.

Once you understand this, a lot of previously confusing behavior becomes predictable. You know why a computed field updates in the form without saving. You know why inverse doesn't fire when a user edits a field. You know why adding @api.onchange on top of @api.depends sometimes changes nothing - and sometimes matters. And when you see @api.depends and @api.onchange on the same method, you know the right question to ask: what exactly is each decorator doing here, and does this method need both?

If you found this useful, the mechanics described here are put to work in a concrete problem in my previous article on circular recomputation and rounding drift in Odoo.

Build seamlessly, securely, and flexibly with MongoDB Atlas. Try free.

Comments

Loading comments...