Odoo Development · intermediate · 12 min read
How the Odoo ORM Works
Environments, recordsets, transactions, prefetch, and the create/write/unlink lifecycle — practical ORM behavior for Odoo 16/17 developers.
Last reviewed 2026-07-30 · Estimated effort: 2–6 weeks typical implementation slice
Table of contents
Odoo's ORM wraps PostgreSQL with Model classes, Environments (cursor, uid, context), and recordsets — ordered sets of ids with lazy field loading and a per-request cache.
Every public method runs inside a database transaction. create, write, and unlink trigger computed fields, constraints, overrides, and bus notifications according to field definitions and decorator order.
Performance and correctness hinge on batching writes, avoiding browse in loops, understanding prefetch, and using sudo() only with a documented reason — not as a default escape hatch.
Django or SQLAlchemy habits do not map one-to-one to Odoo. Recordsets are immutable by id set; operations return new recordsets and may flush SQL lazily until the cursor commits or rolls back.
This article explains the request lifecycle from env['model'].search() through write hooks — enough to debug production issues and write efficient server code.
self.env carries cr (cursor), uid (user id), context (lang, allowed_company_ids, active_test, etc.), and registries of models. self is a recordset; empty recordsets are falsy, singletons use ensure_one().
search(domain, limit, order) returns a recordset; browse(ids) attaches ids without a query until fields are read. Union, intersection, and filtered() operate on id sets in memory.
- ▸with_context() returns a new env — use for temporary flags, not global mutation
- ▸sudo() bypasses ACL and record rules — audit every use
- ▸active_test=False in context includes archived records in search
create(vals_list) accepts a dict or list of dicts for batch insert. Default values come from fields, defaults in context, and @api.model_create_multi overrides. write(vals) merges into cache then flushes SQL.
unlink() may be blocked by ondelete policies on Many2one fields, constraint methods, or override logic. Mail followers and attachments often need explicit cleanup in overrides.
@api.depends decorates compute methods; store=True persists columns and enables search/sort. Related fields (related=) are readonly mirrors unless readonly=False with inverse.
@api.constrains runs Python validation after writes touching listed fields. SQL constraints (_sql_constraints) enforce uniqueness at database level — prefer for simple uniqueness.
Reading one field on N records triggers prefetch of the same field group for all ids in the recordset — unless prefetch is disabled in context. N+1 loops often come from iterating recordsets and reading unstored computed fields per row.
Use mapped(), filtered_domain(), read_group() for aggregates instead of Python sums on large sets. For bulk updates, write once on a combined recordset when values are identical.
HTTP requests and cron jobs typically wrap work in one transaction. UserError and ValidationError roll back the current request unless caught improperly in controllers.
self.env.cr.savepoint() isolates sub-work — useful for try/import patterns in wizards. Test cases use TransactionCase so each test method rolls back automatically.
- ✓Prefer search + write over browse assumptions on ids from untrusted input.
- ✓Batch create with a list of dicts when importing hundreds of rows.
- ✓Document sudo() blocks with why ACL bypass is safe.
- ✓Use read_group for dashboards instead of loading full recordsets.
- ✓Invalidate only when necessary — ORM cache coherence is usually automatic after write.
- ✓Profile with log_sql or query counters on staging before optimizing micro-loops.
- !Calling browse(external_id) without checking existence — use exists().
- !Writing in compute methods without @api.depends guard — infinite recursion.
- !Using SQL cursor directly and bypassing ORM cache invalidation.
- !sudo() to hide permission bugs instead of fixing ACL and record rules.
- !Searching inside for loops instead of one search with ('id', 'in', ids).
- !Mutating context dict in place instead of with_context(new_key=value).
The ORM is Odoo's contract with PostgreSQL and business rules. Master environments, batching, and hook order before layering complex integrations.
Deepen field design and relationships in the models article — where schema choices constrain every query.
Model
Python class
Fields
Stored / computed
SQL table
Persistence
Cache / prefetch
Performance
- modelfields
- fieldstable
- modelcache(prefetch)
Frequently asked questions
When should I use raw SQL?
Rarely — for reporting at scale or one-off migrations. Always parameterize queries and respect company rules. Return to ORM for anything that triggers business logic.
What is the difference between @api.model and regular methods?
@api.model means the method does not require a recordset of ids — it operates on the model itself, like create defaults or name_search overrides.
How do onchange methods differ from compute?
onchange runs in the web client for UX previews — not persisted until save. Compute runs server-side on save/recompute and can be stored. Do not rely on onchange for enforcement.
Does unlink always DELETE FROM?
By default yes for real models. Some models use active archiving instead; unlink may be overridden to deactivate. Check _active_name and business overrides.
Related articles
Understanding Odoo Module Structure
How Odoo addons are laid out: manifests, models, views, security, data, and dependency order — the foundation every developer ticket should respect.
View →Models, Fields, and Relationships
Designing Odoo models: field types, Many2one/One2many/Many2many, inheritance modes, constraints, and schema decisions that survive reporting and upgrades.
View →Testing Odoo Modules
Automated testing for Odoo addons: TransactionCase, SavepointCase, test tags, mocking RPC, view loading checks, and CI patterns for Odoo 16/17.
View →Writing Upgrade-Friendly Code
Engineering patterns that survive Odoo version upgrades: inheritance over fork, xpath stability, migration scripts, noupdate data, and avoiding private API coupling.
View →ORM performance or transaction bugs blocking delivery?
We profile custom code paths, fix N+1 patterns, and harden write/unlink overrides on live estates.
