Skip to Content

Odoo Development

Odoo Constraints: Python vs SQL, and What Changed in v19

Odoo gives you two places to enforce a rule: Python, which is flexible and only runs when the ORM runs, and the database, which is absolute and cannot be bypassed. Odoo 19 changed how you declare the second one, and the old syntax now does nothing at all while logging a warning nobody reads.

Part of our guide to Odoo Development

Two enforcement layers, two guarantees

A Python constraint is a method decorated with @api.constrains. It runs on create and write through the ORM, can read anything, and raises a friendly error.

A database constraint is enforced by PostgreSQL. It applies to every write reaching the table, including imports and raw SQL, and it cannot be bypassed by anything.

That difference is the whole basis for choosing. Python is expressive but conditional on the ORM being involved. SQL is inflexible but absolute.

The v19 change to SQL constraints

Through Odoo 18, database constraints were declared as a list of tuples:

_sql_constraints = [
    ("code_uniq", "UNIQUE(code)", "The code must be unique."),
]

In Odoo 19 that attribute is no longer supported. The framework checks for it and logs a warning saying to define models.Constraint on the model instead. It does not raise. It does not stop the install. It simply creates no constraint.

That failure mode deserves attention, because it is the worst kind: a module carried forward from v18 loses every database-level rule it had, while installing cleanly and behaving normally until a duplicate arrives.

The current form is a class attribute:

from odoo import fields, models

class ProjectCode(models.Model):
    _name = "project.code"
    _description = "Project Code"

    code = fields.Char(required=True)
    budget = fields.Float()

    _code_uniq = models.Constraint(
        "UNIQUE(code)",
        "The code must be unique.",
    )
    _budget_positive = models.Constraint(
        "CHECK(budget >= 0)",
        "Budget cannot be negative.",
    )

The definition is the SQL that goes after ADD CONSTRAINT, so UNIQUE(...), CHECK(...) and FOREIGN KEY (...) REFERENCES ... all work. The message is shown to the user on violation, and an empty message gets a generic one.

If you maintain the same module across versions, this is a real down-port hazard in both directions: forward, the list becomes inert; backward, models.Constraint does not exist. It is one of the checks worth having in the module upgrade pass rather than trusting to memory.

Python constraints

from odoo import api, models
from odoo.exceptions import ValidationError

class ProjectTask(models.Model):
    _inherit = "project.task"

    @api.constrains("date_start", "date_end")
    def _check_dates(self):
        for task in self:
            if task.date_start and task.date_end and task.date_end < task.date_start:
                raise ValidationError("The end date cannot be before the start date.")

Three things to get right.

Loop over self. Like compute methods, constraints receive a recordset.

Raise ValidationError. It is the exception the interface presents as a clean message rather than a traceback. Note that odoo.exceptions.Warning was removed and importing it now fails outright, so use UserError or ValidationError.

Name every field you check in the decorator. @api.constrains only fires when one of the named fields is written. A check reading a field not in the list will not run when only that field changes, which quietly permits exactly the state you were trying to prevent.

That last point is the mirror of the depends problem in computed fields, and it fails the same silent way.

What Python constraints do not catch

They run through the ORM. Anything else bypasses them:

  • Direct SQL, including in migration scripts
  • A database restore carrying data that would not pass today's rules
  • Bulk operations written to skip validation for speed

So a rule that must be true of the data itself, rather than of the way users enter it, belongs in the database. Uniqueness is the clearest example: a Python uniqueness check also has a race condition, since two concurrent transactions can each see no duplicate and both commit.

One difference in how failures surface

A database constraint violation arrives as an IntegrityError from PostgreSQL, not as a ValidationError. Odoo maps the message where it can, but code that catches ValidationError expecting to handle both will not catch this one.

That matters when a constraint is expected to fail as part of normal flow, such as an import that tolerates duplicates. Catch the right exception, or check before writing.

Choosing quickly

  • Uniqueness, non-negative amounts, required combinations of columns: SQL.
  • Rules involving related records, company settings, or the record's state: Python.
  • Rules that must survive imports and migrations: SQL, and add a Python check too if you want a friendlier message first.
  • Rules that are really business policy and change with configuration: Python, or arguably not a constraint at all but a check at the point of action.

The common mistake is putting everything in Python because it is easier to write, then discovering during a data migration that none of the rules held, because none of the data came through the ORM.

FAQ

Questions, answered.

What replaced _sql_constraints in Odoo 19?

A models.Constraint class attribute taking the SQL definition and a message. The old _sql_constraints list is no longer supported: Odoo logs a warning that the attribute is not supported and creates no constraint, so a module ported without this change loses its database rules silently.

Should I use a Python constraint or a SQL constraint in Odoo?

Use SQL when the rule is simple, absolute and must hold no matter how data arrives, such as uniqueness or a positive value. Use Python when the rule needs related records, configuration or conditional logic. SQL cannot be bypassed; Python only runs when the write goes through the ORM.

Why does my constraint not fire on import?

Python constraints run through the ORM, so anything writing directly to the database skips them entirely. A database constraint is enforced by PostgreSQL and applies to every write regardless of origin, which is exactly the difference that matters during a data migration.

With CODEerts

Need something built properly?

We are certified Odoo partners. We build custom Odoo modules that extend the framework rather than fight it, and we publish our own apps on the Odoo Store.

See how we can helpBook a call