Odoo 19 changed how a model declares a database constraint, and it made the change quietly. A module that still uses _sql_constraints keeps installing, keeps updating and keeps passing its tests. The only sign is one warning in the server log, and the rule the module declared is no longer enforced by anything.

That is the kind of failure I care most about: nothing breaks on the day, and the cost turns up months later as duplicate records nobody can explain. This post walks through what the 19.0 source does with the old attribute, what happens to a constraint that already existed before the upgrade, and how to check your own modules and database.

This article is also published on dev.to.

What changed

Up to Odoo 18, a model declared its SQL constraints as a list of tuples:

class TradeAccount(models.Model):
    _name = "trade.account"

    reference = fields.Char(required=True)

    _sql_constraints = [
        ("reference_unique", "UNIQUE(reference)", "Trade account references must be unique."),
    ]

In 18.0 there is no other way to write it. Odoo's own 18.0 core declares _sql_constraints in 174 files and has no models.Constraint anywhere, so nearly every custom module written before 19 uses the old form.

Odoo 19 replaces the list with declarations on the class itself: models.Constraint, models.Index and models.UniqueIndex.

class TradeAccount(models.Model):
    _name = "trade.account"

    reference = fields.Char(required=True)

    _reference_unique = models.Constraint(
        "UNIQUE(reference)",
        "Trade account references must be unique.",
    )

What Odoo 19 does with the old attribute

When 19.0 builds a model class and finds _sql_constraints on it, it logs this and carries on (odoo/orm/model_classes.py):

Model attribute '_sql_constraints' is no longer supported, please define models.Constraint on the model.

It does not convert the list. Constraints are now applied from the model's table objects, which are the models.Constraint and models.Index declarations, and nothing reads _sql_constraints any more. For a module written for 19 that still uses the old form, the constraint is simply never created.

On an upgraded database, the update removes it

It is reasonable to hope that a database upgraded from 18 keeps the constraints it already had. Reading the 19.0 source, a module update removes them instead:

  1. At the end of loading, Odoo reflects each model's table objects into ir.model.constraint and marks their external ids as loaded (_reflect_model in ir_model.py). A constraint declared only in _sql_constraints is not a table object, so its external id is never marked.
  2. ir.model.data._process_end then deletes whatever the updated module no longer provides, and a constraint whose external id was not loaded counts as no longer provided.
  3. Deleting an ir.model.constraint record runs ALTER TABLE ... DROP CONSTRAINT and logs Dropped CONSTRAINT.

So the update that brings a custom module onto 19 is the update that drops its old constraints, and the log records it as routine housekeeping. I traced this through the 19.0 source rather than on a live upgrade, so check your own database with the query further down rather than relying on my reading or on the hope.

It happens in real projects

OpenSPP, a social-protection platform built on Odoo, runs on 19. A May 2026 refactor migrated three of its _sql_constraints declarations to models.Constraint. Another, the rule that each change request type has only one document rule per reason, stayed in the old form until an August 2026 fix (#395), and until then duplicates could be saved. The project now runs a lint check of its own for Odoo 19 patterns. None of their tests had failed, because nothing fails.

How to find them

In the code

grep -rn "_sql_constraints" --include=*.py addons/

Any hit in a module whose __manifest__.py version starts with 19. is a constraint that does not exist. In a 16, 17 or 18 module it is correct, and changing it there would break the module.

In the log

Restart the server and search the log for is no longer supported. There is one line per model still using the old form. After a module update, also search for Dropped CONSTRAINT.

In the database

List what PostgreSQL actually enforces on the table, and compare it with what the module declares:

SELECT conname, pg_get_constraintdef(oid)
FROM pg_constraint
WHERE conrelid = 'trade_account'::regclass
  AND contype IN ('u', 'c');

If a uniqueness rule is missing, find out whether duplicates have already got in:

SELECT reference, count(*)
FROM trade_account
GROUP BY reference
HAVING count(*) > 1;

With a checker

This is one of the rules in Nexa, the Odoo checker I build, as odoo.sql-constraints-are-ignored-from-19. It reads the file and the series in the module's manifest, and it stays silent on 16 to 18, where the old form is the only form. Run over OpenSPP's history, it fires on all four of the fixed declarations as the files stood before each fix, and on none after. With no model involved, it takes a fraction of a second:

nexa check models/trade_account.py --no-model

  impact     likelihood
  high       defect     line 18    odoo.sql-constraints-are-ignored-from-19
    TradeAccount declares _sql_constraints, which Odoo 19 ignores: the registry logs a
    warning and creates no constraint, so nothing enforces it -- declare each one as
    models.Constraint

Migrating without losing the rule a second time

The change itself is small, as the two examples at the top show. Three details decide whether the rule survives it.

  • Clear the duplicates first. If rows already break the rule, adding the constraint fails. Odoo 19 treats that as a warning, "not a deployment showstopper" in the words of its own comment in registry.py, and carries on without the constraint. So migrating the declaration is not enough on its own: run the duplicates query, merge or fix what it finds, and only then update.
  • Keep the name. Odoo 18 named the constraint {table}_{key}. Odoo 19 names it {table}_{attribute}, with the leading underscore removed. So ("reference_unique", ...) becomes _reference_unique = models.Constraint(...), and both produce trade_account_reference_unique. With the same name, the update recognizes the existing constraint as the one you declared instead of removing it.
  • Keep the definition text identical. Odoo stores the definition as a comment on the constraint and compares it with what you declare. If the text differs, even in spacing, it drops the constraint and adds it again, and that re-add is exactly the step that fails quietly when duplicates exist.

Then run the pg_constraint query again after the update. The constraint should be there, under the name you expect.

The general lesson

A framework upgrade does not only break things loudly. Sometimes it retires a way of saying something, keeps accepting the old way, and records the difference in a log line that nobody reads. The only defense is to check what the database actually enforces, not what the code appears to declare.

If you run Odoo and would rather have someone read your custom modules for this and the other failures that raise no error, that is what my Odoo reliability reviews are for. They are read-only, fixed-price, and start at £200.