How Odoo’s ORM Works: Models, Fields, and the API Beneath

Abstract technical illustration for the article: How Odoo's ORM Works: Models, Fields, and the API Beneath

Open any Odoo module and you will find Python classes that look almost too simple to be doing real database work. A class with a _name and a few field declarations somehow becomes a database table, a form view, and an API endpoint. There is no SQL in sight and no hand-written queries in the business logic. The engine behind all of that is the Odoo ORM — the layer between your Python code and PostgreSQL that decides what a model is, how its data is stored, and how you are allowed to touch it. If you develop for Odoo, the ORM is not a detail you can defer.

What the ORM actually does

Odoo's ORM (object-relational mapper) is an abstraction layer that lets you work with the database in object-oriented terms instead of SQL. You define business objects as Python classes, declare their data as fields, and the framework creates the tables, enforces data types, and translates your method calls into queries. This removes SQL injection as a practical concern in model code and keeps access rules and validation centralized, so the web client, the API, and data imports all obey the same rules. The ORM reference describes models as being instantiated once per database, with every model instance being a recordset — an ordered collection of records.

Models: Python classes with a database behind them

Every model starts with a class inheriting from models.Model and a _name attribute in dot notation — the model's technical name:

from odoo import models, fieldsclass LibraryBook(models.Model):    _name = "library.book"    _description = "Library Book"    name = fields.Char(string="Title", required=True)    author = fields.Char(string="Author")    published_date = fields.Date(string="Published On")

When the module containing this class is installed, the ORM creates a database table for it, one column per stored field. Fields are defined as class attributes, and the field's user-visible label defaults to a capitalized version of the attribute name — name becomes “Name” — unless you override it with string. One rule from the reference docs is worth memorizing early: you cannot define a field and a method with the same name on a model, because the last definition silently overwrites the earlier ones.

The three model kinds

Not every model is a permanent business record. Odoo gives you three base classes, and choosing the right one is a design decision with real consequences:

  • models.Model — the default: regular persisted records like sales orders and partners. Data stays until you delete it.
  • models.TransientModel — temporary data: wizards, import dialogs, configuration flows. Stored in the database but vacuum-cleaned automatically, subject to the model's _transient_max_count and _transient_max_hours limits.
  • models.AbstractModel — shared behaviour with no table of its own. Mixins live here, and so can your own reusable field/method bundles.

The same access-rights machinery — access control lists and record rules — applies to transient models, so “temporary” never means “unprotected”.

Fields: the vocabulary of your data

Fields are descriptors: objects that manage how values are stored, converted, validated, and displayed. The simple field types cover the obvious cases — Char, Text, Html, Integer, Float, Monetary, Boolean, Date, Datetime, Binary, Selection — and each accepts keyword arguments that control behaviour beyond the data type:

  • string — the label users see.
  • required — the field must have a value; enforced by the ORM.
  • readonly — affects the UI only. Code can still assign the field, which surprises people exactly once.
  • default — a static value or a callable receiving a recordset and returning one.
  • index — creates a database index. It has no effect on non-stored fields.
  • copy — whether the value is duplicated when a record is copied. Defaults to True for normal fields but False for one2many and computed fields.
  • store — whether the field has a database column at all. Defaults to True, and False for computed fields — a detail that matters enormously once you start writing computed fields.

Relational fields: how models connect

Business data is a graph, and three field types express it:

  • Many2one — “this record points at one record of another model,” stored as a foreign key. partner_id = fields.Many2one("res.partner", string="Customer") is the most common field in all of Odoo.
  • One2many — the inverse view: “all records of another model that point back at me.” It takes the child model and the name of the child's many2one field. A one2many is virtual — no column, and it cannot be assigned directly the way a many2one can.
  • Many2many — a free-form many-to-many link kept in a dedicated relation table, like the tags on a product.

Two facts from the documentation govern how you read these. First, accessing a relational field always returns a recordset — empty if nothing is linked, never None. Second, one2many and many2many fields are where the prefetching machinery (below) matters most: naive loops over them are where performance goes to die in badly written modules.

Inheritance, two ways

Odoo inheritance confuses people because two mechanisms share a similar name and serve opposite purposes:

  • Extension with _inherit, no _name — your class modifies the existing model in place. New fields land on the original table; new methods become available everywhere. This is how custom modules add fields to res.partner without touching core code.
  • Prototype inheritance with _inherit plus _name — creates a brand-new model that copies all fields and methods of the parent, for building variant models from an existing template.
  • Delegation with _inherits — composition. Your model stores a many2one to a parent record and transparently exposes the parent's fields, while the values stay on the parent's table. Odoo's own res.users delegates to res.partner, which is why every user has a partner record behind them.

The recordset: the abstraction everything runs on

Here is the mental model that makes Odoo code click. You almost never handle a bare “record object”. The framework's own words: every model instance is a recordset — an ordered collection of records — and records have no explicit representation of their own. A single record is just a recordset of size one, and iterating a recordset yields singleton recordsets.

This is why model methods are written to handle any number of records at once, and why the canonical pattern in a compute or button method is for record in self: rather than assuming self is one thing. Set operations feel natural too: filtered(), mapped(), and sorted() return new recordsets without touching the originals. Writing methods that work on zero, one, or many records — and calling ensure_one() explicitly when your logic genuinely needs exactly one — is the difference between code that survives real data and code that works only in demos.

CRUD, domains, and the environment

Every ORM operation starts from the environment, self.env, which bundles the database cursor, the current user, their companies, and the context. From a model you reach any other model with self.env["res.partner"], and the CRUD surface is small: create(vals) inserts (accepting a dict or a list of dicts for batch creation), search(domain) finds records matching a filter, browse(ids) fetches by ID, write(vals) updates the whole recordset in place, and unlink() deletes.

The domain argument is Odoo's filter language: a list of (field, operator, value) tuples like [("state", "=", "done"), ("amount_total", ">", 1000)], composable with prefix operators for AND, OR, and NOT. Domains appear everywhere — in search(), in record rules, in view filters — so learning to read them fluently pays off across the entire framework. Prefer batch operations: one write() on a hundred records beats a hundred single-record writes, because each call carries fixed overhead in access checks and recomputation.

The cache and prefetching: why your loops are fast

The ORM keeps a per-transaction cache of field values: the first time you read record.name it queries the database, the second time it reads from memory. On top of that sits prefetching: when one field must be read on one record, the ORM reads that field for a larger set of records at once and fills the cache for all of them — usually the recordset you are iterating over.

The payoff is dramatic. The documentation's canonical example: looping over a thousand partners and printing two fields each would naively issue two thousand queries; with prefetching it issues one, because all simple stored fields are columns of the same table and are fetched together efficiently. Prefetching also follows relations: read partner.country_id inside a loop and the countries get prefetched as a batch too, so the whole loop costs two queries instead of a thousand.

The practical lesson: iterate recordsets, not query results. The ORM's speed comes from you handing it the whole collection up front and letting the prefetch heuristics do their job.

Four habits that save you debugging time

  1. Never give a method the same name as a field. The later definition silently wins.
  2. Write every model method for multi-record recordsets. Loop over self; use ensure_one() only when the logic demands it.
  3. Remember readonly is a UI hint. Enforce real invariants with constraints.
  4. Batch your writes and creations. Pass lists to create(), call write() on the full recordset.

Further reading

Similar Posts

Leave a Reply