Skip to content

Odoo 19 ORM API: Complete Developer Guide

DeployMonkey Team · March 22, 2026 18 min read

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_constraints tuples
  • 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

DecoratorPurposeVersion
@api.depends('field')Compute method triggers on field changeAll
@api.constrains('field')Validation on field changeAll
@api.onchange('field')UI-only change (form view)All
@api.modelMethod does not operate on recordsetAll
@api.model_create_multiCreate method accepting list of dicts12+
@api.readonlyMethod safe for read-only replica18+
@api.deprecatedMark method as deprecated19+
@api.privateMark method as internal19+

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 place

Constraints (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=True on computed fields used in search/filter/sort
  • Batch operations — Use create(list) instead of looping create(dict)
  • Prefetching — Access fields on the full recordset, not individual records
  • Use read_group instead of reading all records and summing in Python
  • Avoid search inside loops — Query once, filter the recordset in Python
  • Use mapped for 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.