What Changed in Odoo 19's ORM
Odoo 19 introduced the most significant ORM refactoring since Odoo 10. The ORM has been split into a package structure under odoo/orm/ with dedicated submodules for fields, models, commands, and domains. The good news: all imports still work via re-exports (from odoo import fields, models, api is unchanged). The changes are internal, but understanding them helps you write better code.
Key changes:
- Domain class — AST-based domain representation replaces list-based domains internally
- Constraint/Index classes — Replace
_sql_constraintstuples - New decorators —
@api.deprecated,@api.private,@api.readonly - ORM package split — Internal reorganization (transparent to module developers)
CRUD Operations
Create
# Create a single record
partner = self.env['res.partner'].create({
'name': 'Acme Corp',
'email': '[email protected]',
'company_type': 'company',
})
# Create multiple records (batch)
partners = self.env['res.partner'].create([
{'name': 'Alice', 'email': '[email protected]'},
{'name': 'Bob', 'email': '[email protected]'},
])Read
# Search returns recordset (lazy loading)
partners = self.env['res.partner'].search([
('is_company', '=', True),
('country_id.code', '=', 'US'),
], limit=10, order='name asc')
# Browse by ID (when you know the ID)
partner = self.env['res.partner'].browse(42)
# Read specific fields (returns list of dicts)
data = partners.read(['name', 'email', 'phone'])
# Search and read in one call
data = self.env['res.partner'].search_read(
[('is_company', '=', True)],
fields=['name', 'email'],
limit=10,
)Update
# Update a single record
partner.write({'phone': '+1-555-0100'})
# Update multiple records (same values)
partners.write({'active': False})
# Update a specific field directly
partner.name = 'Acme Corporation'Delete
# Delete records
partner.unlink()
# Delete with domain
self.env['res.partner'].search([
('active', '=', False),
('write_date', '<', '2024-01-01'),
]).unlink()Domain Filters
Domains are lists of tuples that filter records. Each tuple is (field, operator, value):
# Basic domain
domain = [('state', '=', 'draft')]
# Multiple conditions (implicit AND)
domain = [
('state', '=', 'sale'),
('amount_total', '>', 1000),
('date_order', '>=', '2026-01-01'),
]
# OR conditions
domain = [
'|',
('state', '=', 'draft'),
('state', '=', 'sent'),
]
# NOT condition
domain = [
'!', ('active', '=', True),
]
# Relational field traversal
domain = [
('partner_id.country_id.code', '=', 'US'),
]
# Common operators
# =, !=, >, >=, <, <=
# in, not in (for lists)
# like, ilike (for text search)
# =like, =ilike (pattern matching)
# child_of, parent_of (for hierarchies)Odoo 19 Domain Class
Odoo 19 introduces an AST-based Domain class internally. For module developers, the list format still works everywhere. The Domain class is useful for programmatic domain manipulation:
from odoo.osv.expression import AND, OR
# Combine domains
combined = AND([domain1, domain2])
combined = OR([domain1, domain2])Computed Fields
class SaleOrder(models.Model):
_inherit = 'sale.order'
# Non-stored computed field (calculated on read)
margin_percent = fields.Float(
string='Margin %',
compute='_compute_margin_percent',
)
# Stored computed field (saved in DB, updated on dependency change)
total_weight = fields.Float(
string='Total Weight',
compute='_compute_total_weight',
store=True,
)
@api.depends('amount_total', 'amount_untaxed')
def _compute_margin_percent(self):
for order in self:
if order.amount_total:
order.margin_percent = (
(order.amount_total - order.amount_untaxed)
/ order.amount_total * 100
)
else:
order.margin_percent = 0.0
@api.depends('order_line.product_id.weight', 'order_line.product_uom_qty')
def _compute_total_weight(self):
for order in self:
order.total_weight = sum(
line.product_id.weight * line.product_uom_qty
for line in order.order_line
if line.product_id.weight
)Key Decorators
| Decorator | Purpose | Version |
|---|---|---|
@api.depends('field') | Compute method triggers on field change | All |
@api.constrains('field') | Validation on field change | All |
@api.onchange('field') | UI-only change (form view) | All |
@api.model | Method does not operate on recordset | All |
@api.model_create_multi | Create method accepting list of dicts | 12+ |
@api.readonly | Method safe for read-only replica | 18+ |
@api.deprecated | Mark method as deprecated | 19+ |
@api.private | Mark method as internal | 19+ |
Environment and Context
# Access the environment
env = self.env
# Access current user
user = self.env.user
# Access current company
company = self.env.company
# Access a model
Partner = self.env['res.partner']
# Change context
records = self.with_context(lang='fr_FR').search([])
# Change user (sudo)
records = self.sudo().search([])
# Change company
records = self.with_company(company_id).search([])Sudo and Access Control
# sudo() bypasses access rules — use sparingly
partner = self.env['res.partner'].sudo().create({...})
# Better: use sudo() only for the specific operation that needs it
def _create_partner_as_admin(self, vals):
"""Create partner bypassing access rules."""
return self.env['res.partner'].sudo().create(vals)
# NEVER use sudo() to fix "access denied" errors without understanding why
# the access was denied in the first placeConstraints (Odoo 19 Style)
class Equipment(models.Model):
_name = 'equipment.item'
_description = 'Equipment'
name = fields.Char(required=True)
serial_number = fields.Char(required=True)
# Odoo 19: Constraint class replaces _sql_constraints
_serial_unique = models.Constraint(
"UNIQUE(serial_number)",
"Serial number must be unique."
)
# Python constraint
@api.constrains('name')
def _check_name(self):
for record in self:
if len(record.name) < 3:
raise ValidationError("Name must be at least 3 characters.")Aggregation (read_group)
# Group sales by month
result = self.env['sale.order'].read_group(
domain=[('state', '=', 'sale')],
fields=['amount_total:sum'],
groupby=['date_order:month'],
orderby='date_order:month asc',
)
# Returns: [{'date_order:month': 'January 2026', 'amount_total': 125000, ...}, ...]Performance Tips
- Use
store=Trueon computed fields used in search/filter/sort - Batch operations — Use
create(list)instead of loopingcreate(dict) - Prefetching — Access fields on the full recordset, not individual records
- Use
read_groupinstead of reading all records and summing in Python - Avoid
searchinside loops — Query once, filter the recordset in Python - Use
mappedfor extracting field values:partners.mapped('email')
Getting Started
Use Claude Code with an Odoo 19 CLAUDE.md for AI-assisted ORM development. Deploy and test on DeployMonkey — free plan includes full Odoo 19 support with AI monitoring.