Ad – 728×90
🔧 Backend Development

Odoo Onchange & Constraints – @api.onchange and @api.constrains

Odoo gives you three mechanisms to validate and react to field changes: @api.onchange for live UI feedback before saving, @api.constrains for server-side validation on save, and _sql_constraints for PostgreSQL-level uniqueness and check rules. Each has a different scope and guarantee — knowing when to use each is essential for building robust Odoo modules.

⏱️ 20 min 🎯 Intermediate 📅 Updated 2026

📋 What You Will Learn

  • The difference between onchange, constrains, and SQL constraints
  • How to write @api.onchange methods with warnings
  • How to raise ValidationError from @api.constrains
  • Defining _sql_constraints for 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 runsImmediately in the browser when a field changes (before save)On every create/write that touches the listed fieldsOn every INSERT/UPDATE at the database level
Runs in shell/xmlrpcNo — UI onlyYesYes
Can modify other fieldsYes — set self.field = valueNo — read-only validationNo
Error shown asSoft warning dialog (optional)Hard error blocking saveHard error blocking save
Best forAuto-filling fields, UI hintsBusiness rule validationUniqueness, 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.

Python
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(' ', '')
⚠️
Onchange only runs in the browser

@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:

Python
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.",
                }
            }
Ad – 336×280

@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.

Python
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."
                    )
💡
constrains runs even from Python/API calls

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.

Python
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 TypeSyntaxUse Case
Unique single columnUNIQUE(column)ISBN, email, reference number
Unique compositeUNIQUE(col1, col2)Title + publisher, name + company_id
Check constraintCHECK(expression)Price >= 0, percentage BETWEEN 0 AND 100
⚠️
NULL values and UNIQUE constraints

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:

Python
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.onchange runs 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.constrains runs on the server on every create/write — raise ValidationError to block the save
  • _sql_constraints create real PostgreSQL constraints — the strongest guarantee, even against ORM bypass
  • Pair onchange (fast feedback) with constrains (hard enforcement) for a great user experience
  • UNIQUE SQL constraints allow multiple NULL values by PostgreSQL design

FAQ

Does @api.onchange run when importing records via a CSV import? +

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.

Can @api.constrains access related model records? +

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.

How do I update an existing _sql_constraints definition? +

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.

Why does my @api.constrains not fire when I write to the record? +

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.