Odoo 20 breaks plenty of things with a clear error: an import that no longer exists, a renamed field, a module that will not install. Those show up on the first run and get fixed. This article is about the others: ten changes that raise no exception at all. The module installs, its tests pass, and the result is no longer the same.
Method. Every point was read in the odoo/odoo source, branch 20.0 (commit 2fec203, 2 November 2026), and compared with branch 19.0 (commit 4d2d4b5, same day). The links in each section point to those exact lines. We ran seven of the ten on 6 November with the same script on two throwaway databases, one on Odoo 19 and one on Odoo 20 (Community, base and account modules); the output is quoted in each section.
What we saw porting our catalog
We maintain a catalog of connectors and modules for Odoo 17, 18, 19 and 20. To measure what breaks on the way to 20, we ported all of it automatically on copies (the migration scripts Odoo ships in odoo/upgrade_code plus our own) and tested every module by installing it on a real Odoo 20 and running its whole test suite. The last full run finished on 1 November 2026, with our code as of 31 October:
| Result of the automatic port to 20 | Modules |
|---|---|
| Install and pass all their tests | 151 |
| Install without any error, with failing tests | 55 |
| Do not install | 12 |
| Total, from 147 repositories | 218 |
The 12 that do not install are the easy part: the log says what is missing. The 55 in the middle installed cleanly, and without tests that walk the real path they would have looked ready. In the log, those tests fail on exceptions (KeyError, AttributeError, TypeError, ValueError) or on assertions that do not hold. The ten changes in this article raise no exception at all: only a test that checks the result sees them, like the ones we wrote on purpose (a user without privileges, an exchange rate dated today, a report compared with its Odoo 19 output), or a probe on a real Odoo 20.
These figures come from the automatic port on copies, not from what is published: a module of ours ships on 20.0 only after passing all its tests on a real Odoo 20.
The ten at a glance
| Change in Odoo 20 | How it shows |
|---|---|
1. t-esc and t-raw | The value comes out blank in reports and on the portal |
2. account.payment.state | paid now means validated but not reconciled |
3. is_company | Without a VAT number, the contact is created as a person |
| 4. Exchange rates | Today's rate only applies from tomorrow |
5. b64decode on a Binary | Corrupt bytes or Incorrect padding |
6. read() on a Binary | A dictionary instead of the base64 string |
7. Fields renamed to uom_id | getattr always falls back to the default |
8. Access rules in ir.access | The module installs with no access rules at all |
| 9. Renamed methods | Your override stops running |
10. migrations/20.0.x | The migration script runs twice |
1. t-esc and t-raw render blank
What happens. The Odoo 20 QWeb engine no longer compiles t-esc or t-raw. In 19 they were still compiled as t-out; in 20 the element renders empty and the log only gets a WARNING: Unknown directives or unused attributes: {'t-esc'}.
How it shows. The invoice PDF or the portal page is generated fine, but the value is missing. In our probe, the same template gives <p>HOLA</p> on 19 and <p></p> on 20. Odoo's OWL 3 migration script (owl3-migration.py) does replace t-esc with t-out, but only in files under /static/: report and portal templates are left as they were.
grep -rnE 't-(esc|raw)=' --include='*.xml' .<!-- Odoo 17-19: prints the name. Odoo 20: empty, only a WARNING in the log -->
<span t-esc="o.partner_id.name"/>
<!-- Odoo 17, 18, 19 and 20 -->
<span t-out="o.partner_id.name"/>Fix. t-out, which exists in 17, 18, 19 and 20. Anything that used t-raw must now arrive as Markup; otherwise t-out escapes it.
Source. 20.0: ir_qweb.py:1927 (the warning) and owl3-migration.py:547-554 (the OWL 3 script only looks at /static/). 19.0: ir_qweb.py:2370-2374 (t-esc and t-raw are still compiled).
2. A paid payment is no longer reconciled
What happens. The state field of account.payment changes its values. In 19: draft, in_process, paid, canceled, rejected. In 20: draft, paid, reconciled, canceled, rejected. The 20 paid is the old in_process (validated, not reconciled) and the old paid is now called reconciled. On invoices, payment_state keeps its 19 values and adds one, todo.
How it shows. A filter on 'paid' still works and returns something else: validated payments that are not reconciled yet. One on 'in_process' does not fail on 20 either: in our probe it returns 0 records with no exception.
grep -rnE "'(in_process|paid)'" --include='*.py' --include='*.xml' . | grep -i payment# Odoo 19: reconciled payments. Odoo 20: validated payments, NOT reconciled yet
payments = env['account.payment'].search([('state', '=', 'paid')])
# Odoo 18, 19 and 20
states = dict(env['account.payment']._fields['state'].selection)
done = 'reconciled' if 'reconciled' in states else 'paid'
payments = env['account.payment'].search([('state', '=', done)])Fix. Review the grep hits that belong to payments by hand: the same 'paid' in an invoice's payment_state still means what it meant.
Source. 19.0: account_payment.py:36-43. 20.0: account_payment.py:37-44.
3. The VAT number decides is_company
What happens. In 20, res.partner.is_company is a computed, stored field that depends on has_vat and commercial_partner_id: a contact is a company if it is its own commercial entity and has a VAT number (not empty, nor "/", "na" or "NA"). In 19 it was a plain boolean.
How it shows. A create() with is_company: True and no VAT number creates a person, with no error. In our probe, without a VAT number, 19 gives True and 20 gives False; with one, True on both. A later write() does set it to True, until Odoo recomputes it because the VAT number or the commercial entity changes. A marketplace connector that creates business customers without a VAT number gets persons, and if it then looks the contact up by is_company to reuse it, it finds nothing and creates a new one on every order. It happened to us while porting one of ours.
grep -rn "is_company" --include='*.py' --include='*.xml' .# Odoo 19: a company. Odoo 20: a person (no VAT number), no error
env['res.partner'].create({'name': 'ACME SL', 'is_company': True})
# Odoo 20: pass the VAT number when you have it,
# and do not look existing contacts up by is_company
env['res.partner'].create({'name': 'ACME SL', 'is_company': True, 'vat': vat})Fix. Pass the VAT number when you have it, do not look contacts up by is_company, and if you write it, check first whether the field is computed: Partner._fields['is_company'].compute.
Source. 20.0: res_partner.py:371 (the field), res_partner.py:942-945 (has_vat) and res_partner.py:950-951 (what it depends on). 19.0: res_partner.py:277 (plain boolean).
4. Today's exchange rate applies tomorrow
What happens. res.currency._get_rates picks the latest rate dated on or before the operation date in 19 ('<=') and strictly before it in 20 ('<'). In 20, a rate dated today does not count for today's operations.
How it shows. In our probe, the pound is at 1.10 yesterday and 1.20 today; converting 100 dollars to pounds dated today gives 120 on 19 and 110 on 20. No error. A process that loads the day's rate in the morning and invoices on the same day books at yesterday's rate, and a test that creates a rate dated today and expects it to apply fails without anything having changed in your module.
grep -rn "res.currency.rate" --include='*.py' .# rate dated today: used today on Odoo 19, from tomorrow on Odoo 20
env['res.currency.rate'].create({'currency_id': gbp.id, 'name': today, 'rate': 1.20})
usd._convert(100, gbp, company, today) # Odoo 19: 120.0 / Odoo 20: 110.0Fix. There is no option in the code to go back to the 19 behaviour. In tests, create the rate dated yesterday. If your integration needs the exact rate of the day, apply it explicitly instead of going through _convert, or store it with the date the 20 rule expects.
Source. 19.0: res_currency.py:126-129. 20.0: res_currency.py:185-187.
5. b64decode(record.field) returns garbage
What happens. In 19, reading a Binary field returned its content as base64. In 20 it returns a BinaryValue, which exposes the raw bytes through Python's buffer protocol. base64.b64decode() accepts any buffer: it reads those raw bytes as if they were base64 and, without validate=True, drops whatever is not in the alphabet.
How it shows. In our probe, on a regular Binary field: with a 12-byte text, 20 returns 9 different bytes and no error; with a PNG, binascii.Error: Incorrect padding. On 19 both come back identical to the original. Depending on the content, an image or a PDF you send to another system arrives corrupt, or the call blows up.
grep -rn "b64decode(" --include='*.py' . # then check which ones read a record's field# Odoo 19: the original bytes. Odoo 20: garbage or binascii.Error
data = base64.b64decode(record.file_data)
# Odoo 17, 18, 19 and 20
value = record.file_data
data = value.content if hasattr(value, 'content') else base64.b64decode(value)Source. 20.0: binary.py:9 (class BinaryValue(Buffer)), binary.py:49 (__buffer__) and fields_binary.py:128-129 (what the field returns when read).
6. read() on a Binary returns a dictionary
What happens. In 20, read() on a Binary field returns {'content': <base64>, 'size': N}, plus filename when there is one. In 19 it returned the base64 string directly.
How it shows. In our probe, read() returns bytes on 19 and a dictionary with content and size on 20. If that value ends up in a str() or a text template headed for another system, the dictionary's text is what gets sent, and nothing fails in Odoo.
grep -rnE "\.(read|search_read)\(" --include='*.py' . # then check which ones read Binary fields# Odoo 19: base64. Odoo 20: {'content': ..., 'size': ...}
b64 = record.read(['file_data'])[0]['file_data']
# Odoo 17, 18, 19 and 20
v = record.read(['file_data'])[0]['file_data']
b64 = v['content'] if isinstance(v, dict) else vSource. 20.0: fields_binary.py:134-146.
7. Renamed fields read through getattr
What happens. In 20, stock.move.product_uom, purchase.order.line.product_uom_id, mrp.bom.product_uom_id and mrp.bom.line.product_uom_id are renamed to uom_id. Using the old name directly raises an error (in a create(), ValueError: Invalid field). The problem is code written for several versions that reads it with a default: on 20, getattr(bom, 'product_uom_id', False) always returns the default.
How it shows. Quantities in the wrong unit: a kit in dozens, or a line in kilograms against a component in grams. It happened to us in a marketplace connector that splits kits into components: on 20 it always fell back to the product's unit.
grep -rnE "(getattr\(|\.get\()[^)]*'product_uom(_id)?'" --include='*.py' .# Odoo 20: always falls back to the product's unit
uom = getattr(bom, 'product_uom_id', False) or product.uom_id
# Odoo 17, 18, 19 and 20
fname = 'uom_id' if 'uom_id' in bom._fields else 'product_uom_id'
uom = bom[fname] or product.uom_idSource. 19.0: mrp_bom.py:46, stock_move.py:67 and purchase_order_line.py:43. 20.0: mrp_bom.py:47, stock_move.py:68 and purchase_order_line.py:49; the create() ValueError, models.py:4151-4153.
8. A module with no access rules installs without a warning
What happens. Odoo 20 replaces ir.model.access and ir.rule with a new model, ir.access, with its own security/ir.access.csv file. Odoo's script that converts access rules (odoo/upgrade_code/19.4-00-ir-access.py) removes the old CSV from the manifest and, to add the new one, looks for a line that starts with 'data': [. If 'data' sits in the middle of a line in your manifest, it writes ERROR <module>: Cannot add security/ir.access.csv to manifest, please add manually and carries on.
How it shows. The module installs with no access rules at all. In 19, a model without access rules triggered a warning at install time ("The models [...] have no access rules"); in 20 that warning is gone. The tests, which run as superuser, pass. A regular user gets "No group currently allows this operation". It happened to us with modules already ported: a test with an unprivileged user caught it, and since then our porting pipeline stops if an ir.access.csv is not loaded.
grep -L "ir.access.csv" */__manifest__.py # manifests that do not load it
grep -n "Cannot add" upgrade.log # the script's own complaint# the script cannot add ir.access.csv here ('data' in the middle of a line)
'depends': ['base'], 'data': ['views/views.xml'],
# here it can
'depends': ['base'],
'data': ['security/ir.access.csv', 'views/views.xml'],Fix. Add 'security/ir.access.csv' to data by hand and write a test that creates and reads a record of each model as a regular user (new_test_user and with_user). It is the only test that sees it.
Source. 20.0: 19.4-00-ir-access.py:415-434 (where it looks for 'data', and the ERROR) and ir_access.py:342 (the message users get). 19.0: loading.py:250-257 (the warning that is gone in 20).
9. Overriding a method Odoo 20 no longer calls
What happens. Python does not warn you when you override a method the base class no longer has: your method exists, but nothing calls it. In 20, the payment framework replaces payment.provider._get_compatible_providers with _find_available_providers, with a different signature (everything after amount becomes keyword-only).
How it shows. If you overrode _get_compatible_providers to hide a payment provider by country or amount, on 20 that provider shows up again at checkout. In our probe, the 19 model has _get_compatible_providers and not _find_available_providers; the 20 one, the other way round.
grep -rhoE "def _[a-z0-9_]+" models/ | sort -u # private methods you override
grep -rn "def _get_compatible_providers" odoo-20.0/addons/ # no output = Odoo 20 no longer has it# Odoo 19
def _get_compatible_providers(self, company_id, partner_id, amount, **kwargs):
# Odoo 20
def _find_available_providers(self, company_id, partner_id, amount, **kwargs):Source. 19.0: payment_provider.py:555, called from portal.py:113. 20.0: payment_provider.py:671.
10. Migrations copied to 20.0.x run again
What happens. It is not new in 20, but it shows up precisely when porting. Odoo runs a migrations/<full version> folder when the installed version is lower than the folder's and the new one is equal or higher. If you port by copying migrations/19.0.1.3.0 to migrations/20.0.1.3.0, then upgrading a database that has the module at 19.0.1.3.1 to 20 runs that script again on data it already transformed. Folders without the series (migrations/1.3.0) only compare the module part and do not run again.
How it shows. The -u finishes fine and the data ends up transformed twice: times shifted again, lines copied twice.
ls -d */migrations/20.0.* 2>/dev/nullmigrations/20.0.1.3.0/post-migrate.py # runs again when a 19.0.1.3.x database moves to 20
migrations/1.3.0/post-migrate.py # compares only 1.3.0: does not run againFix. Folders without the series, idempotent scripts (that check the current state before touching anything) and a test that runs the migration twice.
Source. 20.0: migration.py:197-210 (lines 205 to 208 for folders without the series, 210 for full-version folders).
And one that is no longer silent: datas on attachments
In September, ir.attachment.create({'datas': ...}) on 20 created an empty attachment with no error at all (in an e-signature connector, the signed PDF would have been saved with zero bytes). Since commit c06a072 of 31 October ("[FIX] base: remove fallback for datas") it fails with AssertionError: _check_contents should handle that (ir_attachment.py:502). The fix is the same and works on 17, 18, 19 and 20: 'raw' with the unencoded bytes. The 20.0 branch still gets changes every week, so whatever you tested in September is worth testing again against today's code.
How to catch them before your users do
- Run each section's grep over your code and review the hits by hand.
- Run your tests as an unprivileged user too, not only as superuser.
- Render your reports and portal pages and compare the text with the Odoo 19 output.
- Create test data dated today, not only yesterday: exchange rates, payments, deadlines.
- Upgrade a 19 database with data to 20 and run the migration twice.
Frequently asked questions
Do Odoo's migration scripts (upgrade_code) fix these changes?
No. None of the scripts in odoo/upgrade_code on the 20.0 branch solves these ten: the access rules one (19.4-00-ir-access) is precisely the one that can leave a module without them, and the OWL 3 one replaces t-esc with t-out only in files under /static/.
Why don't the tests catch them?
Because they run as superuser, with data that does not go down those paths, and without looking at what gets produced. A report with a blank field raises nothing, and 'paid' is still a valid value. You need tests that check the result: the report text, the converted amount, what a regular user sees.
Can the code be made ready on 19 before porting?
In several cases, yes: t-out instead of t-esc and 'raw' on attachments already work on 17, 18 and 19, and reading renamed fields through _fields leaves the same code ready for 20.
Useful links inside FlexigoTech
Connectors for Odoo 17, 18, 19 and 20
FlexigoTech builds connectors and modules for Odoo 17, 18, 19 and 20, and tests them this way on each version before publishing.

