What's new in the Odoo 20 Python framework, checked against the code

SQL-backed computed fields, one ir.access model instead of access rights and record rules, lazy binary values, typed config parameters and a Python 3.12 / PostgreSQL 16 baseline: the framework team's Odoo Experience 2026 talk, with each claim checked in the 20.0 code.

At Odoo Experience 2026, Krzysztof Magusiak from Odoo’s framework team presented what changed in the ORM and the server for Odoo 20. This guide is for developers who maintain custom modules: it sums up the talk, then checks every concrete claim against the Community code of 20.0. Several of these changes break existing code. See What developers should check before you port a module.

What's new in the Python framework? — Odoo · Watch on YouTube

What’s new

Building SQL from fields

The query tools now give you a table object for each alias (2:01). Its attributes are the model’s fields, so table.company_id gives the column, and table.company_id.partner_id.name adds the joins it needs, each one only once. Fields referenced this way are flushed automatically before the query runs (3:41). In 19.0 the same job needed _field_to_sql() calls. The speaker says the API will move to Python 3.14 template strings (t-strings) once 3.14 becomes the minimum version, which he expects in about two years (3:07).

compute_sql: group and sort by non-stored computed fields

A computed field can now declare a compute_sql method that returns the same value as SQL (5:01). That is enough for grouping and sorting by the field without storing it (6:00). Until now a report grouped by a computed value usually meant store=True and recomputing on every change to its dependencies. The table argument is the table object above, and fields used inside it can be SQL-computed as well. An example from project in 20.0:

is_favorite = fields.Boolean(
    compute='_compute_is_favorite', compute_sql='_compute_sql_is_favorite',
    compute_sudo=True, search='_search_is_favorite', ...)

def _compute_sql_is_favorite(self, table):
    return SQL(
        "%s IN (SELECT project_id FROM project_favorite_user_rel WHERE user_id = %s)",
        table.id, self.env.uid,
    )

In the Q&A (39:34) the speaker explained that compute_sql comes on top of a Python compute, not in place of it. Having both makes the intent clear when the SQL mishandles an edge case such as NULL. If you only want to write SQL, the Python compute can run the compute_sql query itself.

Custom domain leaves

Domain.custom(to_sql=...) puts arbitrary SQL into a domain, and the result combines with &, | and other domains like any condition (8:09). The speaker presents it as new. In the code it already existed in 19.0. What 20.0 changes is the signature: to_sql now takes the table object instead of (model, alias, query), and there is a new optimize hook.

Smaller API changes

  • env.website (9:38). A minimal website model now lives in base, and env.website returns the current one, so model code no longer reaches for request.website, which fails in cron jobs where there is no request.
  • Assigning translations as a dict (10:29). record.name = {'en_US': 'Chair', 'fr_FR': 'Chaise'} sets several languages in one write. copy_data now carries translations this way, and copy_translations() is gone.
  • Cache invalidation between workers (11:53). Commit and rollback now signal and check registry and cache changes automatically. signal_changes() and reset_changes() are no longer needed.
  • One module update at a time (12:33). Updating modules takes an exclusive lock, so other workers can no longer load a half-migrated registry, and two parallel install or upgrade runs no longer collide.
  • Typed config parameters (13:48). ir.config_parameter has get_bool, get_int, get_float, get_str and matching setters. A stored "False" now reads as false.

x2many fields no longer depend on who read them first

In 19.0, reading record.tag_ids as a normal user and then record.sudo().tag_ids (or the other way round) could return the first result for both, depending on which line ran first (14:50). In 20.0 the cache keeps every related record together with a flag saying whether the current user can see it, and filters on each read. A computation done as sudo no longer leaks extra records into the non-sudo flow. The speaker says other fields will get the same fix next year.

Access rights and record rules merged into ir.access

ir.model.access and ir.rule become a single model, ir.access (18:19): one line gives a group CRUD operations on a model, with an optional domain. Every check (check_access, has_access, _filtered_access) goes through _access_domain(operation). According to the speaker, Odoo’s own _search overrides that added access logic, such as the one on attachments, were rewritten as domains, so one screen shows every rule. Expressing access as a domain also prepares merging it with the user’s search domain so both can be optimised together.

The new access operator lets a model inherit access from a related record (21:46). The portal rule on sale order lines in 20.0:

id,name,model_id,group_id/id,operation,domain
sale_order_line_rule_portal,Portal Sales Orders Line,sale.order.line,base.group_portal,r,"[('order_id', 'access', 'read')]"

The line is readable if its order is, and changes to the order’s rules apply to the lines automatically. A separate talk on the new access model was scheduled for the next day.

Binary fields rewritten

Binary fields now return a BinaryValue, a lazy object based on Python’s Buffer with content, filename, mimetype, size and checksum (23:47). The cache holds raw bytes instead of base64, which is about a third smaller, and encoding happens only at the edges (30:28). Asking for size doesn’t load the content. The bin_size context, which used to hand computes a string like "5 KB" instead of the data, is no longer used in the ORM.

Over RPC, read now returns a dict with content (base64), size and, if set, filename (28:42). web_read sends a checksum instead of the content, so image URLs stay cached until the file itself changes. Before, any change to the record invalidated them (29:24). ir.attachment.datas is removed. Use raw (31:28).

Requirements and server

  • Python 3.12 and PostgreSQL 16 are the new minimums (19.0: 3.10 and 13), following the two latest Ubuntu LTS releases and Debian stable (32:10). The speaker says Odoo already runs on Python 3.14.
  • pytz is gone: Odoo uses the standard zoneinfo instead (32:53).
  • HTTP server on h11 (33:29): Odoo no longer serves requests with werkzeug’s development server, and the gevent (longpolling) worker uses the same server.

Performance

  • filtered(), indexing and concatenation keep the prefetch set, so a loop over filtered records no longer runs one query per record (33:58).
  • Past 10,000 records the ORM stops fetching the whole row when you read one field, to keep huge recordsets from running out of memory (34:24).
  • selection not in (…) is rewritten as in the other values, so an index can be used (35:39).
  • x2many subqueries use EXISTS / NOT EXISTS, which is much faster than NOT IN (36:05).
  • LEFT JOINs that a condition makes equivalent to INNER JOINs run as inner joins (36:35). The speaker adds that the EXISTS change is partly backported to 19.0 and the join change will follow there.

Populate and PDF engines

The speaker previewed two more talks: generating test data with a command-line tool built on the Faker library, duplicated to simulate large volumes (37:38), and a PDF layer in base with pluggable engines: wkhtmltopdf and Odoo’s own Paper Muncher, which is in alpha. He expects wkhtmltopdf to be deprecated at some point, and says an engine based on Chrome, for example, could be added as a module using the same API (38:12).

Checked in the code

Checked in odoo/odoo at 20.0 (commit 3ff8b8d9, October 23, 2026), compared with 19.0.

Claim Status Where Note
Table object with field attributes, auto-joins, auto-flush Confirmed 5521c6b3 TableSQL / FieldSQL in odoo/orm/query.py.
compute_sql for grouping and sorting non-stored fields Confirmed 28d08d01, fields.py Needs compute, and an explicit compute_sudo. Missing either gives a warning, not an error.
Domain.custom added Different domains.py Already in 19.0. 20.0 changes the to_sql signature and adds optimize.
website model in base, env.website Confirmed ae81b6f6, 9d97e0e9 get_current_website removed in 2545a833.
Assign translations as a dict; copy_translations removed Confirmed 96e2b5c2, 8102a1f9
Cache invalidation signalled automatically Confirmed 9fca3bea signal_changes / reset_changes removed from the registry.
Lock while updating modules Confirmed e6bcdfb9 Shared lock to load, exclusive to update.
Typed config parameter getters Confirmed 22fa8e67 Also removes get_param / set_param: a4f28796.
x2many cache pollution fixed Confirmed 3d20ab40 Per-model env._access_cache.
ir.access replaces ir.model.access and ir.rule Confirmed 77c17e71 Modules ship security/ir.access.csv. _access_domain() in models.py.
access operator for delegating access Confirmed domains.py, sale/security/ir.access.csv Works on many2one and id.
upgrade_code migrates rules from XML Confirmed 19.4-00-ir-access.py The database side is handled by Odoo’s upgrade scripts, which are not public.
_has_field_access can be overridden (Q&A) Confirmed models.py Public has_field_access(), override _has_field_access().
BinaryValue with content, filename, mimetype, size, checksum Confirmed 41fe2ebd, 23a7d8cc, ae1213ee odoo/tools/binary.py.
RPC binary as dict; checksum in web_read Confirmed fields_binary.py Keys content, size, filename.
ir.attachment.datas removed Confirmed 7662537c
Python 3.12, PostgreSQL 16 minimum Confirmed d357125e, fbe93945 odoo/release.py.
pytz replaced by zoneinfo Confirmed 6fdb3671 No import pytz left in 20.0 (93 files in 19.0).
HTTP server on h11 Confirmed 17164382, 165c6a20 odoo/http/server.py.
filtered() keeps prefetching Confirmed 040d9711
No whole-row prefetch past 10,000 records Confirmed fe186b07 BIG_RECORDSET_SIZE = 10_000.
selection not in rewritten as in Confirmed domains.py _optimize_type_selection.
x2many subqueries use EXISTS Confirmed 4ccdedec, 69b05f46
LEFT JOIN turned into INNER JOIN Different 022d3248, c7947f7e Odoo writes INNER JOIN only for required many2ones. For other joins with a condition, it adds joined.id IS NOT NULL so PostgreSQL promotes the join itself.
Populate CLI with Faker Different 970e3d96 19.0’s odoo-bin populate was renamed duplicate. It copies existing data. No Faker anywhere in Community 20.0 or master.
Pluggable PDF engines: wkhtmltopdf and Paper Muncher Confirmed base_report_wkhtmltox, base_report_paper_muncher Both LGPL-3. The engine is chosen in ir.actions.report.

Not checkable in code: the plan to move to t-strings, the plan to fix cache pollution for all fields, the merged user-and-access domain optimisation, and the 19.0 backport of the join change (the speaker says that PR is still open).

Community, Enterprise and Online

Everything above is framework code in odoo/odoo under LGPL-3, so it applies to Community, Enterprise, Odoo.sh and Odoo Online alike. Both PDF engine modules are in Community. The only piece that isn’t public is the data migration of access rules in Odoo’s upgrade service. The upgrade_code script that rewrites your module’s files is public.

What developers should check

Breaking changes for custom modules

Several 19.0 APIs are removed outright in 20.0, not deprecated. Run odoo-bin upgrade_code on your modules first, then grep for the items below.

  • Access files. ir.model.access.csv and ir.rule records become ir.access (security/ir.access.csv with operation like crud or r). Rules on child models can usually become [('parent_id', 'access', 'read')]. Overrides of _search that filtered for security should move to _access_domain, ideally as data instead of code.
  • Config parameters. get_param() and set_param() no longer exist. Use get_str, get_bool, get_int, get_float and the set_* counterparts.
  • Binary fields. Code that base64-decodes a binary field value, or relies on bin_size, must work with BinaryValue (.content for bytes). RPC clients get a dict instead of a base64 string. Replace ir.attachment.datas with raw.
  • Time zones. Replace pytz with zoneinfo (ZoneInfo('Europe/Brussels'); no more localize()).
  • Python and PostgreSQL. Deploy on Python 3.12+ and PostgreSQL 16+.
  • Website. Replace request.website in model code with self.env.website. The website.get_current_website() model method is gone.
  • Translations. Remove overrides of copy_translations() and write a {lang: value} dict instead.
  • Registry signalling. Drop manual signal_changes() / reset_changes() calls.
  • SQL in fields. Overrides of _field_to_sql for a single field can become compute_sql. Domain.custom(to_sql=…) callbacks now receive the table object only.
  • Test data. Scripts calling odoo-bin populate must use odoo-bin duplicate.

Sources

Found a mistake? ask@odoodigest.com · Corrections logAll changes in 20.0 →

My stack

Pick the versions and apps you run. Weekly digests, this week's table and All changes then show only what concerns you. Saved in this browser.

Versions
Apps
Modules