Enable Dark Mode!
whats-new-in-the-odoo-20-orm-a-complete-developer-guide.jpg
By: Arjun V P

What's New in the Odoo 20 ORM? A Complete Developer Guide

Technical Odoo 20 Odoo Community Odoo Enterprises

Odoo 20 was released in September 2026, and the Odoo 20.0 branch is marked as final. The most significant changes implemented via ORM were delivered in the 19.1 - 19.4 Online versions, but if you haven't followed them, you will have to cope with a whole bunch of changes, including a new security approach, a new module structure, typed configuration parameters, and a Python 3.12 base.

Each migration is shown below with its corresponding code. The post concludes with a migration checklist based on order of breaking changes.

At a glance

ChangeFirst appearedImpact
models.Constraint / Index / UniqueIndex18.1Replaces _sql_constraints
Domain class18.1Replaces list-based domain building
Typed ir.config_parameter API19.1get_param / set_param is no longer defined in the 20.0 model file
Field.compute_sql19.1Search, sort and group on computed fields
BinaryValue binary fields19.2 / 19.3Binary fields no longer hold base64
access domain operator19.3Reuse comodel permissions in domains
ir.access19.4Merges ACLs and record rules
Thread-safe, layered ormcache19.4New cache semantics and named caches
Python 3.12–3.14, PostgreSQL 16+20.0Hard requirement

1. The odoo/orm package layout

The ORM resides in its own package. The package’s docstring provides information on the load order. There is one Registry per database. Decorators contain the method decorators. Environments provide Transaction and Environment. The fields base class gets loaded first, followed by the scalar fields, and finally models contain the base model classes. The docstring further says that features get exposed in other packages, and developers must not import from odoo.orm.

From the imports in the 20.0 sources, the package contains:

odoo/orm/
+-- registry.py          # Registry
+-- environments.py      # Transaction, Environment
+-- decorators.py        # @api.* decorators, re-exports ormcache from .cache
+-- cache.py             # ormcache implementation
+-- fields.py            # Field base class
+-- fields_misc.py  fields_temporal.py  fields_textual.py
+-- fields_relational.py  fields_binary.py  fields_properties.py
+-- domains.py           # Domain
+-- commands.py          # Command
+-- models.py            # BaseModel
+-- table_objects.py     # Constraint, Index, UniqueIndex
+-- query.py             # Query, TableSQL
+-- identifiers.py  types.py  utils.py

This list comes from imports, so it may not be exhaustive.

Your imports stay the same:

Python

from odoo import api, fields, models
from odoo.fields import Domain
from odoo.tools import SQL

odoo.models.AbstractModel is defined as an alias for odoo.orm.models.BaseModel. Should you be using imports of odoo.orm.* or old odoo.osv paths (which are deprecated since 19.0), change over to the new public entry points. (The Odoo package uses the native PEP 420 namespaces since 18.2.)

2. ir.access replaces ACLs and record rules

This is the most significant alteration. The ir.access model combines the ir.model.access and ir.rule models into one model. As per the Odoo release notes, this update is to remove record rules and add a domain to the access rights. For details about the model used, refer to the Security in Odoo documentation. I read the master documentation, which still talked about the two models.

Each ir.access record has:

  • model_id: the model it applies to.
  • operation: a combination of c, r, u, and d, such as crud, r or ru. There is no default, so you must always set it explicitly.
  • group_id (optional) and domain (optional).
  • active, and note.
  • kind: computed as permission if a group is set, otherwise restriction.
  • is_standard, plus the for_read, for_write, for_create and for_unlink helper booleans.

The semantics:

  • A record with a group grants permission to that group's users for the listed operations on records matching the domain.
  • A record without a group restricts the operations to matching records for everyone.
  • Restrictions take priority over permissions. The allowed set is (p1 OR p2 OR …) AND (r1 AND r2 AND …).

Core modules now ship security/ir.access.csv:

csv

id,name,model_id,group_id/id,operation,domain
access_my_order_user,my.order user,my.order,base.group_user,crud,
access_my_order_own,my.order own lines,my.order,sales_team.group_sale_salesman,r,"[('user_id', '=', user.id)]"
access_my_order_company,my.order company limit,my.order,,crud,"[('company_id', 'in', company_ids)]"

The domain is a string evaluated with the same context as former ir.rule domains: user, time, company_id and company_ids.

Things worth knowing:

  • Domains are validated on save. Accesses with a domain cannot target ir.access itself.
  • The access domain operator (19.3) checks the permissions of a comodel. A condition like ('order_id', 'access', 'read') means the user can read the related order. Error messages even use this to list which groups would satisfy an access.
  • customize() copies a standard access and deactivates the original, so you can edit your own copy.
  • Access data is cached under the stable ormcache and cleared automatically when records change.
  • In debug mode, access errors list the failing accesses ("Blame the following accesses").
  • Existing check_access, has_access and _filtered_access (18.0) still apply.

3. models.Constraint / Index replace _sql_constraints

Declare schema objects as private class attributes (the name must start with _):

Python

class CourseTag(models.Model):
    _name = 'x.course.tag'
    _description = 'Course Tag'
    name = fields.Char(required=True)
    sequence = fields.Integer()
    qty = fields.Integer()
    _qty_positive = models.Constraint("CHECK (qty >= 0)", "Quantity cannot be negative.")
    _name_uniq = models.UniqueIndex("(name)", "Tag names must be unique.")
    _name_seq_idx = models.Index("(name, sequence)")

Rules from table_objects.py:

  • The database name for the constraint is {table}_{name}, where the underscore at the beginning is stripped, so that _name_uniq will keep the same name as _sql_constraints.
  • A name cannot be mangled (__x). A Constraint whose name ends with _not_null emits a warning since PostgreSQL 18 uses that name for NOT NULL constraints.
  • Messages may be strings or callables (env, diag) > str, and Index definitions may be callables from the registry to define conditional indexes.
  • Upon update, the index definition is written into the index comment. The indexes that already exist without any comments are untouched so that the support can modify indexes without having the ORM to undo it.

The migration process is purely mechanical; ('name_uniq', 'unique(name)', 'msg') transforms to _name_uniq = models.Constraint('unique(name)', 'msg'), while index-based uniqueness is transformed to UniqueIndex. From what I have read in the models.py file version 20.0, the schema objects are added from _table_objects. There was no clear indication regarding _sql_constraints removal.

4. compute_sql: computed fields you can search, sort, and group

Field.compute_sql (19.1) names a method that returns an SQL expression for a computed field. The field then needs no stored column to be searchable, sortable, or groupable.

Python

duration = fields.Integer(
    compute='_compute_duration',
    compute_sql='_compute_sql_duration',
    compute_sudo=False,
)
@api.depends('start', 'end')
def _compute_duration(self):
    for course in self:
        course.duration = (course.end - course.start).days if course.start and course.end else 0
def _compute_sql_duration(self, table):
    return SQL("(%s - %s)", table.end, table.start)

From the field source:

  • The function takes (self, table) and should return an SQL object.
  • Fields are considered searchable if they are stored, have a search function, or have compute_sql.
  • One needs to explicitly define compute_sudo, along with compute_sql, or Odoo will give a warning. Odoo gives a warning if there is no computation for that field.
  • The related fields that are not stored will automatically get the compute_sql if all fields from that chain can be computed with SQL, which will make them searchable and sortable.
  • Use search= for non-SQL code.
  • This is used in the core ir.access module for kind and is_standard fields.

5. The Domain class

Domain should be used to create domains instead of concatenating lists. The security manual points out that list concatenation is a vulnerability due to the fact that the user-supplied prefix operators alter the domain.

python

from odoo.fields import Domain
d = Domain('state', '=', 'draft') & (
    Domain('user_id', '=', self.env.uid) | Domain('user_id', '=', False)
)
d &= Domain('order_line', 'any', Domain('qty', '>', 0))
records = self.search(d)
Domain.AND([d1, d2]); Domain.OR([d3, d4])
Domain.TRUE; Domain.FALSE
list(d)                      # serialize to the prefix list form
Domain(old_list_domain)      # parse a list into a Domain

Also available:

  • ~d negation; optimize(model); validate(model); iter_conditions() and map_conditions().
  • Domain.custom(to_sql=...) for custom SQL conditions.
  • The any! operator (any, no access check).
  • Date part expressions like 'birthday.month_number'.
  • Dynamic date parts such as '-3d +1H' and '=monday -1w'.

Search functions on fields get inputs that have been pre-optimized: = is converted to in, and booleans to in [True]. Search functions must return NotImplemented when encountering operators that are unsupported, and the ORM will re-attempt the query using the equivalent operators.

6. ormcache from odoo.api, named caches and invalidation

ormcache is exposed on odoo.api, and its implementation sits inside the ORM package:

python

from odoo import api
@api.ormcache('key', 'type_', cache='stable')
def _get(self, key, type_='str'): ...
@api.ormcache()                 # default cache
def _compute_something(self): ...

The cache= argument selects a named cache. Core uses 'stable' for rarely changing data: system parameters and access definitions.

Invalidation has several layers:

  • env.invalidate_ormcache('stable') (or env.transaction.invalidate_ormcache('stable')) clears a named cache. The default name is 'default'.
  • On a model, _clear_cache_name names a cache that is cleared automatically on create, write, and unlink. _clear_cache_on_fields limits that to specific fields, and None means all fields. ir.config_parameter sets _clear_cache_name = 'stable', which is why its getters need no manual clearing.

The 19.4 release changed the machinery underneath (#255059, #261736):

  • Transaction-layered. Each transaction starts a new cache layer on top of the registry cache. Reads fall back through layers, a rollback discards the layer, and a commit merges it down.
  • Signalling follows the transaction lifecycle. Creating the first Environment for a cursor checks signaling; cr.commit() signals changes, and cr.rollback() resets without signalling.
  • Thread-safe. There is no more manual cross-worker clearing in your code.

I could only confirm ormcache on odoo.api. If you use ormcache_context or ormcache_multi, check that they still exist on your build. Also note that cached values should be small and hashable.

7. Typed ir.config_parameter getters and setters

The old get_param(key, default) returned a string, or the default whenever the stored value was falsy. That led to bugs like bool(get_param(key, True)) always being truthy. The new API is typed:

python

ICP = self.env['ir.config_parameter']
batch = ICP.get_int('my_module.batch_size', 100)
enabled = ICP.get_bool('my_module.enabled')
rate = ICP.get_float('my_module.rate', 0.5)
url = ICP.get_str('my_module.endpoint', 'https://example.com')
ICP.set_int('my_module.batch_size', 250)
ICP.set_str('my_module.endpoint', None)   # becomes undefined

Behaviour:

  • Getters will only return the default value if the value is either undefined or invalid.
  • An invalid value (e.g., 'False', which would be read with get_int()) will return the default value and log a warning without an error.
  • set_int, set_float, and set_str consider None or False to be "undefined", but set_bool considers None to be undefined and False to be a valid value.
  • A stored zero is valid, so use get_int(key) or the default if you want falsy values.
  • The getters check the read permissions and will cache the results under ormcache.
  • I read the ir_config_parameter.py version 20.0, and there were no get_param or set_param. Grep for your code for both and move to the typed ones.

For XML data, initialize with noupdate:

<data noupdate="1">
  <function model="ir.config_parameter" name="set_str"
            eval="('my_module.endpoint', 'https://example.com')"/>
</data>

8. BinaryValue for binary fields

There are no more base64-encoded strings within binary fields using ORM. Now, the Binary field will always give BinaryValue, which is a lazy, immutable, and buffer-like object. The content is stored as raw data in the cache, which is about one-third smaller than base64. Base64 encoding is done only for RPCs.

Reading:

python

if record.bin:                      # empty data is falsy
    data = record.bin.content       # bytes
    text = record.bin.decode()
    b64 = record.bin.to_base64()
    mime = record.bin.mimetype
    size = record.bin.size          # use this, not len()
    digest = record.bin.checksum    # sha256
    with record.bin.open() as f:
        f.read()
bytes(record.bin); memoryview(record.bin)

Writing:

python

from odoo.tools.binary import BinaryBytes
record.bin = BinaryBytes(b'hello', filename='hello.txt')  # explicit raw bytes
record.bin = "AAAA"                  # base64 str, as coming from RPC
attachment.raw = b'hello'            # raw bytes still work on the raw field

Simple byte arrays for the Binary type are ambiguous (are they raw or base64-encoded?), so use BinaryBytes wrapper. read() will return base64-encoded data, and the bin_size context variable is still available. The migration will consist of searching for b64encode/b64decode functions around Binary columns. A related PR suggests removing data, but I could not verify its implementation.

9. Named caches and the model-level cache hooks

Section 6 talks about Named Cache. There are two hooks at the Model level, namely _clear_cache_name and _clear_cache_on_fields, and 20.0 attributes, _allow_sudo_commands and _explanation of the BaseModel class. For caching the model queries, one should use a named cache with these hooks rather than clearing the cache in the create/write/unlink methods.

10. Python 3.12–3.14 and the upgrade_code scripts

In the 20.0 branch, odoo/release.py sets:

python

MIN_PY_VERSION = (3, 12)
MAX_PY_VERSION = (3, 14)
MIN_PG_VERSION = 16

Therefore, Odoo 20 requires Python 3.12 to 3.14 and PostgreSQL 16 and later. Code is written in 3.12 syntax: PEP 695 generics (def get_int[T](...), class Field[T]) and type aliases. Linters and type checkers should be able to process it, and none of it will work on Python 3.10 and 3.11. Example from requirements.txt:

  • lxml 6.0.2, Pillow 12.1.1, greenlet 3.3.2 and vobject 0.9.9 for Python 3.14.
  • psycopg2 2.9.10 and PyPDF 5.4.0 from 3.13.

To migrate source code automatically, run Odoo's built-in tool (available since 18.0) and review the diff:

bash

./odoo-bin upgrade_code --addons-path /path/to/your_addons

Execute it against a clean git tree. The wrappers like odoo provide options like --from, --to, --script, --glob, and --dry-run; hence, check the help for odoo-bin upgrade_code --help. The ir.access PR had modified the upgrade_code, but the exact name of the scripts used for 20.0 could not be verified. Upgrade scripts need to be written against module/migrations/version/{pre, post, end}-*.py using odoo.upgrade.util methods.

11. Other ORM changes worth knowing

  • Field write sequencing (19.4). Updates and inverses now execute in (write_sequence, field_index) order in create() and write().
  • Cache pollution prevention (19.3). Fetching an x2many will return only those records that have access.
  • Simplified API for Model.concat and Model.union (19.3), and multiple translations may be written in one write (19.3).
  • Remove request in models (19.4). Use env.website instead of request in models.
  • HTTP Server using h11 (19.4).
  • _init_column is deprecated from version 20.0; replace with Field.init_storage, a method for initializing columns to null values (not allowed to use ORM). _table_has_rows is also deprecated.
  • @api.readonly decorator methods that run over the readonly cursor through RPC.
  • XML-RPC and JSON-RPC API (/xmlrpc, /xmlrpc/2, /jsonrpc) have been marked as deprecated and scheduled for removal from Odoo 20. Check if your integrations are still relying on these APIs.

12. Migration priority checklist

P0: nothing starts until these are done

  • Upgrade to Python 3.12-3.14 and PostgreSQL 16+.
  • Migrate ir.model.access.csv and ir.rule data to ir.access (migration explicitly done on each row).
  • Migrate _sql_constraints to models.Constraint / UniqueIndex / Index.
  • Migrate get_param/set_param to the typed getters/setters.
  • P1: runtime breakage

  • Audit Binary fields: Do not assign base64 or raw bytes anymore; use BinaryBytes, .content, and .to_base64().
  • Replace list-based operations on domains with Domain.
  • Change all cache code to @api.ormcache(..., cache=...), invalidate_ormcache and _clear_cache_name.
  • Remove the request from the model methods; change _init_column to init_storage.
  • Re-test everything that relies on field inverse/updated order.
  • P2: improvements

  • Execute the command `upgrade_code` and take a look at the diff.
  • Include `compute_sql` to the computed fields that should be searchable/sortable/groupable.
  • Replace ORM cache calls with named caches.
  • Check RPC integration modules for removal.

Odoo 20 represents a definite step forward in terms of ORM design and security. Switching from the use of ir.access, declarative models.Constraint and models.Index, typed getters of ir.config_parameter, and introduction of the new BinaryValue class make most of the uncertainty in the codebase obsolete. Python 3.12+ and PostgreSQL 16+ are mandatory now. Start with the P0 items on the checklist, run odoo-bin upgrade_code on a freshly checked-out git repository, and then fix runtime problems with Binary fields, domains, and caching.

To read more about What Are the ORM Methods in Odoo 19, refer to our blog What Are the ORM Methods in Odoo 19.


Frequently Asked Questions

What are the minimum Python and PostgreSQL versions for Odoo 20?

Odoo 20 needs Python 3.12 to 3.14 and PostgreSQL 16 or newer. The source code also relies on 3.12 syntax, like PEP 695 generics, so it will not work on Python 3.10 or 3.11.

What replaces ir.model.access and ir.rule in Odoo 20?

They both become part of an ir.access model. Every record has a model, an operation (e.g., crud or r), a group (optional), and a domain (optional). Those records that have a group will allow access, whereas those records that do not will limit access.

Do I still need to use _sql_constraints?

No. The constraints and indexes are now defined as class attributes using models.Constraint, models.Index, and models.UniqueIndex. The migration is largely mechanical in nature, and a name like _name_uniq preserves the same database constraint name as earlier.

How do I read and write system parameters now?

The old methods get_param and set_param are deprecated, and now there are type-specific methods like get_int, get_bool, get_float, and get_str, and their counterparts like set_*. They provide default values only when there are no values or invalid values.

How do Binary fields behave differently in Odoo 20?

Binary fields no longer contain base64-encoded strings. They give a lazy and immutable BinaryValue type with methods such as .content, .size, and .mimetype. You should always assign raw bytes using the BinaryBytes type. Base64 encoding is reserved only for remote procedure calls (RPC).

If you need any assistance in odoo, we are online, please chat with us.



0
Comments



Leave a comment



WhatsApp