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
| Change | First appeared | Impact |
| models.Constraint / Index / UniqueIndex | 18.1 | Replaces _sql_constraints |
| Domain class | 18.1 | Replaces list-based domain building |
| Typed ir.config_parameter API | 19.1 | get_param / set_param is no longer defined in the 20.0 model file |
| Field.compute_sql | 19.1 | Search, sort and group on computed fields |
| BinaryValue binary fields | 19.2 / 19.3 | Binary fields no longer hold base64 |
| access domain operator | 19.3 | Reuse comodel permissions in domains |
| ir.access | 19.4 | Merges ACLs and record rules |
| Thread-safe, layered ormcache | 19.4 | New cache semantics and named caches |
| Python 3.12–3.14, PostgreSQL 16+ | 20.0 | Hard 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.