📋 What You Will Learn
- The difference between onchange, constrains, and SQL constraints
- How to write
@api.onchangemethods with warnings - How to raise
ValidationErrorfrom@api.constrains - Defining
_sql_constraintsfor database-level uniqueness - Practical examples: ISBN uniqueness, price validation, quantity warnings
Onchange vs Constraints — When to Use Each
These three tools solve different problems in the validation layer:
| @api.onchange | @api.constrains | _sql_constraints | |
|---|---|---|---|
| When it runs | Immediately in the browser when a field changes (before save) | On every create/write that touches the listed fields | On every INSERT/UPDATE at the database level |
| Runs in shell/xmlrpc | No — UI only | Yes | Yes |
| Can modify other fields | Yes — set self.field = value | No — read-only validation | No |
| Error shown as | Soft warning dialog (optional) | Hard error blocking save | Hard error blocking save |
| Best for | Auto-filling fields, UI hints | Business rule validation | Uniqueness, check constraints |
@api.onchange
@api.onchange methods run client-side (triggered by the browser) when the user changes a listed field on a form view. They modify self in-place — any changes to self.field_name are reflected immediately in the UI without saving.
from odoo import models, fields, api
class LibraryBook(models.Model):
_name = 'library.book'
_description = 'Library Book'
title = fields.Char(string='Title', required=True)
isbn = fields.Char(string='ISBN-13')
category_id = fields.Many2one('library.category', string='Category')
suggested_price = fields.Float(string='Suggested Price')
sale_price = fields.Float(string='Sale Price')
stock_quantity = fields.Integer(string='Stock Quantity', default=0)
@api.onchange('category_id')
def _onchange_category_id(self):
"""Auto-suggest a price range when category changes."""
if self.category_id and self.category_id.default_price:
self.suggested_price = self.category_id.default_price
# Return a notification warning — soft, user can dismiss and continue
return {
'warning': {
'title': 'Price Suggestion',
'message': (
f"Suggested price for category '{self.category_id.name}' "
f"is {self.category_id.default_price:.2f}. "
"You can override this if needed."
),
}
}
@api.onchange('stock_quantity')
def _onchange_stock_quantity(self):
"""Warn when stock is critically low."""
if self.stock_quantity is not False and self.stock_quantity < 3:
return {
'warning': {
'title': 'Low Stock Warning',
'message': f"Only {self.stock_quantity} copies remain. Consider reordering.",
}
}
@api.onchange('isbn')
def _onchange_isbn(self):
"""Format ISBN: remove spaces and dashes."""
if self.isbn:
# Strip formatting — store clean digits only
self.isbn = self.isbn.replace('-', '').replace(' ', '')
@api.onchange is a UI-only mechanism. It is never called when you create or update records via Python code, shell, or the XML-RPC/JSON-RPC API. Never rely on onchange for data integrity — use @api.constrains or _sql_constraints for that.
Onchange on One2many Lines
You can react to changes on One2many child lines by listening to the parent field that contains them. Changes to any subfield in the list trigger the onchange:
class LibraryOrder(models.Model):
_name = 'library.order'
line_ids = fields.One2many('library.order.line', 'order_id', string='Order Lines')
total_amount = fields.Float(string='Total', compute='_compute_total', store=True)
@api.onchange('line_ids')
def _onchange_line_ids(self):
"""Warn if order total exceeds budget."""
total = sum(line.subtotal for line in self.line_ids)
if total > 500:
return {
'warning': {
'title': 'Budget Exceeded',
'message': f"Order total {total:.2f} exceeds the 500 budget limit.",
}
}
@api.constrains
@api.constrains runs on the server whenever a record is created or updated and any of the listed fields are included in the write operation. If the method raises ValidationError, the save is aborted and the error is shown to the user.
from odoo import models, fields, api
from odoo.exceptions import ValidationError
class LibraryBook(models.Model):
_name = 'library.book'
isbn = fields.Char(string='ISBN-13')
sale_price = fields.Float(string='Sale Price')
due_date = fields.Date(string='Due Date')
borrow_date = fields.Date(string='Borrow Date')
@api.constrains('sale_price')
def _check_sale_price(self):
for book in self:
if book.sale_price < 0:
raise ValidationError(
f"Sale price cannot be negative. Got: {book.sale_price:.2f} for '{book.title}'."
)
@api.constrains('isbn')
def _check_isbn_format(self):
for book in self:
if book.isbn:
clean = book.isbn.replace('-', '').replace(' ', '')
if len(clean) not in (10, 13):
raise ValidationError(
f"ISBN '{book.isbn}' is invalid. ISBN must be 10 or 13 digits."
)
@api.constrains('borrow_date', 'due_date')
def _check_dates(self):
for loan in self:
if loan.borrow_date and loan.due_date:
if loan.due_date < loan.borrow_date:
raise ValidationError(
"Due date cannot be earlier than the borrow date."
)
Unlike onchange, @api.constrains runs every time the listed fields are written — from the UI, from Python code, from XML-RPC, from shell. It is your reliable last line of defense before data hits the database.
SQL Constraints (_sql_constraints)
_sql_constraints is a class attribute (a list of tuples) that maps to real PostgreSQL constraints. They are the strongest form of constraint — enforced at the database level, even if Odoo ORM is bypassed.
class LibraryBook(models.Model):
_name = 'library.book'
isbn = fields.Char(string='ISBN-13')
sale_price = fields.Float(string='Sale Price')
title = fields.Char(string='Title')
publisher_id = fields.Many2one('res.partner', string='Publisher')
_sql_constraints = [
# (constraint_name, sql_expression, error_message)
(
'isbn_unique',
'UNIQUE(isbn)',
'An ISBN must be unique. This ISBN already exists in the database.',
),
(
'sale_price_positive',
'CHECK(sale_price >= 0)',
'Sale price must be zero or greater.',
),
(
'title_publisher_unique',
'UNIQUE(title, publisher_id)',
'A publisher cannot have two books with the same title.',
),
]
The constraint name becomes the PostgreSQL constraint name (prefixed with the table name). The SQL expression is any valid PostgreSQL constraint expression. The message is shown to the user when the constraint fires.
| SQL Constraint Type | Syntax | Use Case |
|---|---|---|
| Unique single column | UNIQUE(column) | ISBN, email, reference number |
| Unique composite | UNIQUE(col1, col2) | Title + publisher, name + company_id |
| Check constraint | CHECK(expression) | Price >= 0, percentage BETWEEN 0 AND 100 |
In PostgreSQL, NULL != NULL. A UNIQUE constraint allows multiple rows with NULL in the unique column. If you need to enforce uniqueness including among NULL values, you need a partial index or an additional @api.constrains check for the NULL case.
Combining Onchange and Constraints
In practice, you often want both: onchange for instant feedback while the user types, and constrains as the authoritative enforcement when saving. Here is a complete example for an ISBN field:
from odoo import models, fields, api
from odoo.exceptions import ValidationError
class LibraryBook(models.Model):
_name = 'library.book'
_description = 'Library Book'
title = fields.Char(string='Title', required=True)
isbn = fields.Char(string='ISBN-13')
price = fields.Float(string='Price')
stock_quantity = fields.Integer(string='Stock', default=0)
_sql_constraints = [
# Database-level uniqueness — strongest guarantee
('isbn_unique', 'UNIQUE(isbn)', 'This ISBN already exists.'),
('price_nonneg', 'CHECK(price >= 0)', 'Price must be non-negative.'),
]
# Onchange: instant UI feedback while user types (runs before save)
@api.onchange('isbn')
def _onchange_isbn(self):
if self.isbn:
clean = self.isbn.replace('-', '').replace(' ', '')
self.isbn = clean # normalize formatting
if len(clean) not in (10, 13):
return {
'warning': {
'title': 'Invalid ISBN',
'message': 'ISBN should be 10 or 13 digits. Please check.',
}
}
@api.onchange('stock_quantity')
def _onchange_stock_quantity(self):
if self.stock_quantity is not False and self.stock_quantity < 0:
return {
'warning': {
'title': 'Negative Stock',
'message': 'Stock quantity cannot be negative.',
}
}
# Constrains: server-side enforcement (runs on create/write — even from API)
@api.constrains('isbn')
def _check_isbn(self):
for book in self:
if book.isbn:
clean = book.isbn.replace('-', '').replace(' ', '')
if len(clean) not in (10, 13) or not clean.isdigit():
raise ValidationError(
f"'{book.isbn}' is not a valid ISBN. "
"An ISBN must contain exactly 10 or 13 numeric digits."
)
@api.constrains('stock_quantity')
def _check_stock_quantity(self):
for book in self:
if book.stock_quantity < 0:
raise ValidationError("Stock quantity cannot be negative.")
Summary
📋 Key Points
@api.onchangeruns in the browser before save — use it to auto-fill fields and show soft warnings- Onchange is UI-only — never rely on it for data integrity
@api.constrainsruns on the server on every create/write — raiseValidationErrorto block the save_sql_constraintscreate real PostgreSQL constraints — the strongest guarantee, even against ORM bypass- Pair onchange (fast feedback) with constrains (hard enforcement) for a great user experience
UNIQUESQL constraints allow multipleNULLvalues by PostgreSQL design
FAQ
No. CSV import uses the ORM create/write methods directly, bypassing the UI layer. Only @api.constrains and _sql_constraints run during imports. Any field auto-filling you did in onchange will not happen during import — make sure your import data is pre-processed or use @api.model_create_multi overrides for create-time defaults.
Yes. You can browse, search, or access any model from inside a constrains method. For example, to check global uniqueness of a field across all records you can call self.env['my.model'].search_count([('field', '=', value)]) > 1. However, be careful about performance — avoid expensive queries in constraints that fire on high-volume models.
Change the constraint definition in your Python code and update the module. Odoo will try to drop and recreate the PostgreSQL constraint. If existing data violates the new constraint, the update will fail with a database error. You must clean up violating data first (via a pre-migration script) before tightening a SQL constraint.
The constraint only fires when one of its listed fields is included in the write. If you write record.write({'other_field': value}) and other_field is not in the @api.constrains decorator, the method does not run. Make sure all fields you want to validate are listed in the decorator.