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_countand_transient_max_hourslimits. - 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 toTruefor normal fields butFalsefor one2many and computed fields.store— whether the field has a database column at all. Defaults toTrue, andFalsefor 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 tores.partnerwithout touching core code. - Prototype inheritance with
_inheritplus_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 ownres.usersdelegates tores.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
- Never give a method the same name as a field. The later definition silently wins.
- Write every model method for multi-record recordsets. Loop over
self; useensure_one()only when the logic demands it. - Remember
readonlyis a UI hint. Enforce real invariants with constraints. - Batch your writes and creations. Pass lists to
create(), callwrite()on the full recordset.
Further reading
- ORM API — Odoo master documentation: the canonical reference for models, fields, recordsets, and the environment.
- Odoo ORM cheat sheet (community): a compact recap of models, fields, CRUD methods, domains, and decorators — handy as a desk reference.
- Computed Fields and Onchanges — Odoo 19.0 tutorial: the official walkthrough of computed fields, the natural next topic after the ORM basics.






