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 was first published on my site.
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:
- At the end of loading, Odoo reflects each model's table objects into
ir.model.constraintand marks their external ids as loaded (_reflect_modelinir_model.py). A constraint declared only in_sql_constraintsis not a table object, so its external id is never marked. -
ir.model.data._process_endthen deletes whatever the updated module no longer provides, and a constraint whose external id was not loaded counts as no longer provided. - Deleting an
ir.model.constraintrecord runsALTER TABLE ... DROP CONSTRAINTand logsDropped 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 producetrade_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.
Top comments (0)