Migrating Custom Modules to Odoo 20: The Silent Breaks
Migrating custom modules to Odoo 20? The costly breaks raise no error: ir.access rows, a valuation sign flip, renamed fields, blank icons, orphaned overrides.
ODXProxy Team · Oct 1, 2026 · 9 min read

When you start migrating custom modules to Odoo 20, the tracebacks are the easy part. A removed method or a renamed field fails on first use, you fix it, and you move on. The expensive breaks are the ones that install cleanly, pass a smoke test, and are wrong. We ported five custom modules from Odoo 19.0 to 20.0 for an Indonesian restaurant group, and every costly issue we found was of the quiet kind. This article lists them, shows the code involved, and describes the verification method that found them.
The project
Five modules were ported:
- a POS import from a third-party point-of-sale system,
- an inter-company stock transfer,
- central-kitchen bill-of-materials distribution,
- tiered purchase-order approval,
- an attachment and receipt-tolerance module.
A sixth was retired rather than ported. Its only job was to widen account.journal.code, which
19.0 declares with size=5. In 20.0 the field has no size limit at all. A line-by-line port would
have re-declared the old width and narrowed the field. That is the first lesson: before porting a
module, check whether 20.0 still has the problem it solved.
To be clear about the evidence: these were static ports, verified against the 20.0 source tree, without a 20.0 production database behind them yet. The Odoo facts below are read from source; none of this has run in production. The custom-module snippets are simplified, with names changed.
Loud breaks: the ones that find you
These raise a traceback on first use, so your tests catch them:
ir.config_parameter.get_param/set_param→get_str,get_int,get_bool,get_floatandset_*.ir.attachment.datas→raw.stock.move.product_uom→uom_id(also on move lines, purchase lines and BoMs)._get_value_from_quotation()lost itsat_dateargument, so an override that passes it positionally raisesTypeError.mrp.bom.consumptionis gone, with no replacement.
Odoo's own upgrade_code scripts handle many of these mechanically. The rest of this article is
about what they don't catch.
Silent break 1: group-less rows in ir.access
Odoo 20 replaces ir.model.access and ir.rule with one model, ir.access. A security file looks
like this:
id,name,model_id,group_id/id,operation,domain
access_po_tier_user,po.tier user,purchase.approval.tier,purchase.group_purchase_user,r,
access_po_tier_manager,po.tier manager,purchase.approval.tier,purchase.group_purchase_manager,crud,The rule in odoo/orm/models.py is access = OR(permissions) AND AND(restrictions). A row with a
group is a permission, and permissions add up. A row without a group is a restriction, like a
global record rule, and restrictions intersect.
That changes the meaning of a common 19.0 pattern. In 19.0, an ir.model.access row with an empty
group granted access to everyone. Port it to ir.access literally and you get a restriction with no
permission beside it. OR([]) is false, so no user can access the model. The module installs
without complaint.
# 19.0 ir.model.access.csv: empty group = everyone may read
access_tolerance_all,tolerance all,model_receipt_tolerance,,1,0,0,0
# 20.0, WRONG: a group-less row is a restriction, and nothing grants access
access_tolerance_all,tolerance all,receipt.tolerance,,r,
# 20.0, RIGHT: what Odoo's own converter (19.4-00-ir-access.py) produces
access_tolerance_all,tolerance all,receipt.tolerance,base.group_everyone,r,Two details are easy to miss. model_id is now the model name, not a model_* xmlid. And the
domain column is where former record rules go, so group-less rows with a domain are legitimate.
Only a model whose rows are all group-less is locked. If your integration suddenly sees fewer
records, or none, over the API after an upgrade, check these rows. For how record rules shape API
results in general, see why Odoo returns fewer records over the API.
Silent break 2: the valuation sign flip
In 20.0, an outgoing move's stock.move.value is stored negative again
(stock_account/models/stock_move.py). 19.0 stored it positive. Any code that divides value by
quantity now gets a negative unit cost.
The inter-company transfer module carried unit cost from the sending company's delivery to the receiving company's receipt. Ported as-is, it looked like this:
# 19.0 logic: value and quantity were both positive for an out move
unit_cost = out_move.value / out_move.quantity
receipt_move.price_unit = unit_cost # on 20.0: negative, booked without errorOn 20.0 every receipt would have been valued at a negative cost. Nothing raises; the numbers are
simply wrong, and an accountant finds them weeks later. The fix uses the helper 20.0 added for this
case, _get_valued_qty(signed=True), which returns a negative quantity for out moves so the ratio
stays positive:
unit_cost = out_move.value / out_move._get_valued_qty(signed=True)20.0 also adds _get_value_from_previous_company(), which values a receipt from its origin move when
that move belongs to another company: one origin move only, and no currency conversion. Core's own
inter-company resupply routes use it. If your custom transfer fits those limits, delegating to it is
better than keeping your own logic.
Silent break 3: a renamed field inside a set of names
The tiered approval module locked certain purchase-line fields once an order was submitted. It
did this by checking incoming vals against a set of field names:
LOCKED_AFTER_SUBMIT = {"product_id", "product_qty", "price_unit", "product_uom_id"}
def write(self, vals):
if self.order_id.approval_state == "submitted" and LOCKED_AFTER_SUBMIT & vals.keys():
raise UserError("This line is locked while the order is awaiting approval.")
return super().write(vals)On 20.0, purchase.order.line.product_uom_id is uom_id. Nothing writes product_uom_id any more,
so the check never matches it, and the unit-of-measure lock quietly stops working. A grep for
.product_uom_id attribute access would not find this, because the name lives in a string.
The general fix is to validate name sets against the model at startup, or in a test:
missing = LOCKED_AFTER_SUBMIT - self.env["purchase.order.line"]._fields.keys()
assert not missing, f"unknown fields in LOCKED_AFTER_SUBMIT: {missing}"Silent break 4: blank icons
The 20.0 backend no longer bundles Font Awesome. It ships Material Symbols (a subset font) and
odoo_ui_icons. A button with icon="fa-copy", an <i class="fa fa-check"> element in a view, or
an activity type with an fa- icon renders as an empty space. No error, no warning. Three of the
five modules had invisible buttons.
Replace them with Material Symbols names that core already uses, such as content_copy,
check_circle, verified or sell. Because the font is subset, a valid Material Symbols name that
core never uses may not be in it. Then click through every view that has a custom button.
Silent break 5: overrides with nothing to override
The POS import module overrode two 19.0 session-closing methods, one to set the accounting date and
one to adjust cash rounding. Odoo 20 rewrote POS session closing. The single journal entry built
from _accumulate_amounts is gone. Closing now runs _validate_session_accounting(), which posts one
out_invoice for sales and one out_refund for refunds, rounds through the invoice cash-rounding
mechanism, and reconciles one payment line per payment method.
The old overrides still load. Python is happy to define a method that nothing calls. They simply never run, so the accounting date and rounding adjustments silently disappear. Only reading the 20.0 source shows that.
You can list candidates from an odoo-bin shell on a 20.0 database: methods your module defines that
no class further down the MRO defines. Each hit is either a deliberate new method or an override
whose target has gone:
# odoo-bin shell -d staging20
MODULE = "odoo.addons.pos_external_import"
for model_name in ("pos.session", "pos.order", "stock.move"):
mro = type(env[model_name]).__mro__
for i, klass in enumerate(mro):
if not klass.__module__.startswith(MODULE):
continue
for name, attr in vars(klass).items():
if callable(attr) and not name.startswith("__"):
if not any(name in vars(parent) for parent in mro[i + 1:]):
print(f"{model_name}.{name}: no parent defines it")The method that found them
Grepping for removed names finds the loud breaks. The quiet ones live in changed attributes:
a dropped size=, a sign flip, a new required scope, a method that still exists in your code but not
in the call graph. What worked:
- A static verifier that resolves every
ref=in the module's XML, evaluates every viewxpathagainst the runtime architecture (the fully inherited view, not the base file) using lxml, and checks every Python field reference against the 20.0 tree. - A "what does 20.0 do instead" read for every override and every touched method. If you cannot point at the 20.0 line that calls your override, assume it doesn't.
- Checks against live data. For the POS work, we needed to know what 19.0 actually produced: which journal entries a closed session created, which payment dates were used, how the POS configuration was set up. We ran read-only queries against the live 19.0 database through ODXProxy, using a dedicated user with read-only groups, so the checks could not change anything:
{
"id": "verify-pos-sessions",
"action": "search_read",
"model_id": "pos.session",
"params": [[["state", "=", "closed"]]],
"keyword": {
"fields": ["name", "config_id", "start_at", "stop_at", "move_id"],
"order": "stop_at desc",
"limit": 20
},
"odoo_instance": {
"url": "https://erp.example.com",
"db": "prod19",
"user_id": 42,
"api_key": "<read-only Odoo user's API key>"
}
}Those results became the expected values for the 20.0 port. move_id exists on 19.0's pos.session
and reflects the single closing entry that 20.0 no longer creates, which is exactly the kind of
difference worth writing down before you switch versions. As always with the proxy, check the HTTP
status first and then the error member, because an Odoo error arrives with a 200. See
Odoo API error handling.
A porting checklist
- For every module, ask whether 20.0 core already does what it does. Retire before you port.
- Run Odoo's
upgrade_codescripts, then convert security toir.access.csv. No model may be left with only group-less rows. - Search for
.value /andvalue /in stock and valuation code; use_get_valued_qty(signed=True). - Validate every set or list of field names against
_fields. - Replace every
fa-icon and click every custom button. - List overrides with no parent method on 20.0 and find what replaced each one.
- Capture expected values from the live 19.0 database before cut-over, read-only.
The rest of the series
- Part 1: Odoo 19 vs Odoo 20: what changed for developers
- Part 2: Odoo 20 API changes that break integrations silently
- Part 3: Odoo XML-RPC is deprecated: migrating to the JSON-2 API
- Part 4: Odoo 20's built-in MCP server: what it exposes
For querying the ORM from outside Odoo, start with Odoo search_read, in depth and the ODXProxy API reference.