Computed fields, onchange and constraints: the logic that makes Odoo «think»
The trio that turns a form into a system with judgement. When to use each one, the most common traps, and why store=True is not a minor decision.
Part of our complete guide: Odoo module development.
A form that just saves what you type isn't much: any spreadsheet does that. What turns Odoo into a real management system is the layer underneath —the one that computes totals, suggests values as you type, and stops nonsense from being saved. That layer rests on three pieces people constantly confuse: computed fields, onchange and constraints. They don't do the same thing, they don't run at the same moment, and choosing the wrong one is the root of half the "weird bugs" we find in audits.
The computed field: a value that derives itself
A computed field isn't filled in: it's derived from others. Total price from quantity and unit price, margin from cost and sale, age from a date. You define it with a compute and tell it what it depends on:
from odoo import models, fields, api
class Property(models.Model):
_inherit = "estate.property"
bedrooms = fields.Integer()
living_area = fields.Float()
total_area = fields.Float(compute="_compute_total_area", store=True)
@api.depends("living_area", "bedrooms")
def _compute_total_area(self):
for record in self:
record.total_area = record.living_area + record.bedrooms * 10
Two non-negotiable details. First, the for record in self: a method always works on a set of records, not on one. Forgetting it is mistake number one for people coming from other frameworks. Second, the @api.depends: it's what tells Odoo when to recompute. Miss a dependency and the field goes stale, and nobody understands why.
store=True or not: the decision people get wrong most
Here's the nuance that separates someone who read a tutorial from someone who has kept a system running in production. A computed field can be stored in the database (store=True) or computed on the fly each time it's read.
If you don't store it, you can't filter or group by it, nor use it in a heavy report without paying the cost again and again. If you store it, you gain speed and the ability to search by it… but you commit to keeping the @api.depends flawless, because that value persists. My rule, after a few scares: store only what you'll actually filter, group, or show in large lists. Everything else, on the fly.
onchange: it helps the user, it doesn't validate
The onchange reacts while the user edits the form, before saving. It's for suggesting: you change the customer and their address fills in, you tick a box and a field appears. It's pure user experience.
@api.onchange("expected_price")
def _onchange_expected_price(self):
if self.expected_price and self.expected_price < 100000:
return {
"warning": {
"title": "Low price",
"message": "Are you sure? It's below market.",
}
}
And here comes the trap that costs money: onchange only fires in the interface. If data comes in through an import, the API or another module, your onchange never hears about it. I've seen it too many times: someone puts a critical business rule in an onchange, it works perfectly in manual testing, and months later they discover the nightly import has been skipping it from day one. Onchange helps; it doesn't protect.
Constraints: the safety net that actually protects
What protects are constraints, and they come in two flavours. The SQL ones are applied directly by PostgreSQL, they're blazing fast and perfect for simple rules like "this can't be negative" or "this is unique":
_sql_constraints = [
("check_expected_price", "CHECK(expected_price > 0)",
"The expected price must be positive."),
]
The Python ones come in when the rule needs real logic —comparing fields, querying other records, depending on a state—:
from odoo.exceptions import ValidationError
@api.constrains("expected_price", "selling_price")
def _check_selling_price(self):
for record in self:
if record.selling_price and record.selling_price < record.expected_price * 0.9:
raise ValidationError(
"The selling price can't be 10% below the expected one."
)
The difference from onchange is the key to everything: a constraint runs always, whether the data came from the form, the API or a mass import. If a rule must hold no matter what, it lives in a constraint. Full stop.
The mental rule I use
When I'm unsure where to put a piece of logic, I ask myself three questions in order: is it a value derived from others? → computed field. Is it a visual aid while the user types? → onchange. Is it a rule that can never be broken? → constraint. Most of the bugs we fix are business logic someone put in an onchange when it should have been a constraint, or stored fields with an incomplete @api.depends.
Mastering this trio is what separates a module that "works in the demo" from one you can trust the day 10,000 records land at once. If the "bug that only shows up sometimes" pattern sounds familiar, it's probably here. In a code review we find it fast, or see it within our custom development service. And if you want the solid foundation, start with the anatomy of a module.
Comments (0)
Be the first to comment.
Sign in to leave a comment.
Sign inComments are reviewed before publishing.
Related articles
Automated actions in Odoo: let the ERP do the boring work
Server actions, automation rules and scheduled jobs: the three pieces that make Odoo react, notify and run on its own —without anyone having to remember.
PDF reports with QWeb in Odoo: from the form to the document that closes deals
QWeb is HTML that turns into PDF. We show you how to build a report, loop over records with t-foreach, and why t-field saves you a thousand headaches.
Inheritance in Odoo: the feature that decides whether your code survives upgrades
Classical extension, delegation and view inheritance. The three ways to modify Odoo without touching the core —and why inheriting instead of copying saves you every migration.