Khichdi InfoTech
Skip to content

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.

Flow: request → env → search/browse → read cache/SQL → write → flush → commit.
  • 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.

ORM performance or transaction bugs blocking delivery?

We profile custom code paths, fix N+1 patterns, and harden write/unlink overrides on live estates.

Business technology and software delivery