Ir al contenido

Constraints en Odoo 19: @api.constrains vs models.Constraint

Migración de _sql_constraints, unicidad y validaciones de negocio
17 de junio de 2026 por
Constraints en Odoo 19: @api.constrains vs models.Constraint
Aitor Atencia

Odoo · Validación · Odoo 19

Validar datos en Odoo no es opcional: evita registros huérfanos, duplicados y estados imposibles. En Odoo 19 conviven @api.constrains (Python) y models.Constraint (SQL). Saber cuándo usar cada uno — y migrar desde _sql_constraints — es clave al actualizar módulos.

Logo Odoo PostgreSQL
Las constraints SQL actúan en PostgreSQL; las Python, en el ORM antes del commit.

Panorama

Dos capas de validación

MecanismoDónde correIdeal paraMensaje al usuario
@api.constrainsPython / ORMReglas de negocio, condiciones complejasValidationError traducible
models.ConstraintPostgreSQLUnicidad, CHECK simplesMensaje definido en la constraint
required=TrueORM + BDCampo obligatorioValidación de formulario

Regla práctica: unicidad e integridad estructural → SQL (models.Constraint). Lógica de negocio que cruza varios campos o modelos → @api.constrains.

Python

@api.constrains — validación en Python

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

class ProjectAgileSprint(models.Model):
    _name = 'project.agile.sprint'
    _description = 'Agile Sprint'

    date_start = fields.Date(required=True)
    date_end = fields.Date(required=True)

    @api.constrains('date_start', 'date_end')
    def _check_dates(self):
        for sprint in self:
            if sprint.date_end < sprint.date_start:
                raise ValidationError(
                    'End date must be on or after start date.'
                )

Características importantes:

  • Se ejecuta en create y write cuando cambian los campos listados
  • Solo se dispara si esos campos están en vals (en write parcial)
  • Puede leer otros registros con search — cuidado con el rendimiento
  • Lanza ValidationError con mensaje en inglés (traducible vía i18n)

Odoo 19

models.Constraint — el sucesor de _sql_constraints

Desde Odoo 19, declara constraints SQL como atributos de clase en lugar de la tupla _sql_constraints:

# Odoo 19 ✅
class PickupZone(models.Model):
    _name = 'pickup.zone'

    zip_code = fields.Char(required=True)

    _zone_zip_unique = models.Constraint(
        'unique(zip_code)',
        'Zip code must be unique.',
    )

# Odoo 17/18 ❌ (deprecado en 19)
    _sql_constraints = [
        ('zone_zip_unique', 'unique(zip_code)', 'Zip code must be unique.'),
    ]

Ventajas del nuevo estilo:

  • Nombre de atributo legible (_zone_zip_unique)
  • Mejor soporte en herramientas y migraciones
  • Misma semántica SQL: UNIQUE, CHECK, etc.
Backend Odoo
Un IntegrityError en logs suele apuntar a una constraint SQL, no a un constrains Python.

Migración

Migrar de _sql_constraints a Odoo 19

Antes (≤18)Después (19)
_sql_constraints = [('name_uniq', 'unique(name)', 'Msg')] _name_uniq = models.Constraint('unique(name)', 'Msg')
('dates_check', 'check(end >= start)', 'Msg') _dates_check = models.Constraint('check(end >= start)', 'Msg')
  1. Renombra cada tupla a un atributo _descripcion único en el modelo
  2. Elimina la lista _sql_constraints
  3. Actualiza el módulo (-u mi_modulo) en entorno de prueba
  4. Verifica en PostgreSQL que las constraints antiguas no duplican las nuevas
  5. Añade test que intente violar la regla y espere error

Unicidad

Patrones de unicidad

# Unicidad simple
_code_unique = models.Constraint(
    'unique(code)',
    'Code must be unique.',
)

# Unicidad compuesta (varios campos)
_project_dates_unique = models.Constraint(
    'unique(project_id, date_start)',
    'Only one sprint per project and start date.',
)

# Unicidad condicional → mejor en Python o índice parcial (avanzado)
@api.constrains('active', 'partner_id')
def _check_one_active_contract(self):
    active = self.search([
        ('partner_id', '=', self.partner_id.id),
        ('active', '=', True),
    ])
    if len(active) > 1:
        raise ValidationError('Only one active contract per partner.')

Para «solo un registro activo por X», @api.constrains es habitual en Odoo; en alta concurrencia valora un índice único parcial en SQL o bloqueo explícito.

Errores

Errores habituales

ErrorConsecuenciaSolución
constrains sin incluir todos los campos leídosNo se ejecuta al cambiar un campo relacionadoAñadir todos los campos al decorador
Validar unicidad solo en PythonDuplicados bajo carga concurrentemodels.Constraint UNIQUE
Mensajes SQL en español hardcodeadosi18n inconsistenteInglés en código + .po
write masivo sin constrains disparadoCampos no listados en decoradorIncluir campos o usar SQL
Mezclar _sql_constraints y models.ConstraintDuplicados al migrar a 19Solo el estilo nuevo en 19

Decisión

¿Qué mecanismo elijo?

SQL Constraint

  • unique(campo)
  • check(campo >= 0)
  • Integridad ante concurrencia

@api.constrains

  • Comparar fechas / estados
  • Validar contra otro modelo
  • Reglas condicionales complejas

Otros

  • @api.onchange — solo UI
  • Override create/write — normalización
  • Tests + documentación

Tests

Probar constraints en tests

from odoo.exceptions import ValidationError
from odoo.tests import TransactionCase

class TestPickupZone(TransactionCase):

    def test_zip_code_unique(self):
        self.env['pickup.zone'].create({{'zip_code': '28001'}})
        with self.assertRaises(ValidationError):
            self.env['pickup.zone'].create({{'zip_code': '28001'}})

Para constraints SQL puras, assertRaises(IntegrityError) o captura del error de Odoo al envolver el IntegrityError de psycopg2.

Resumen

En Odoo 19 usa models.Constraint para unicidad y CHECK en SQL; reserva @api.constrains para reglas de negocio que el ORM debe explicar al usuario. Migra _sql_constraints antes de producción, no dupliques mecanismos y testea violaciones explícitas. La capa SQL protege la integridad; Python, la semántica del dominio.

en Odoo
Campos calculados en Odoo: store=True, @api.depends y trampas
Ciclos de dependencia, invalidación y campos que no se actualizan