Skip to content
Back to blog
DevelopmentPythonORM

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.

COConsultor Odoo27 May 20264 min read

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.

#Development#Python#ORM
Share article

Comments (0)

Be the first to comment.

Sign in to leave a comment.

Sign in

Comments are reviewed before publishing.

Ready to get the most out of Odoo?

Tell us your challenge. In a first 30-minute call we'll tell you how Odoo can help, no strings attached.