← Blog
odooodoo-20migrationormir-accesscustom-modules

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

Migrating Custom Modules to Odoo 20: The Silent Breaks — ODXProxy blog cover

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.

This is part 5 of a five-part series on Odoo 19 vs Odoo 20. Part 1 summarises the framework changes this article builds on.

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_float and set_*.
  • ir.attachment.datas → raw.
  • stock.move.product_uom → uom_id (also on move lines, purchase lines and BoMs).
  • _get_value_from_quotation() lost its at_date argument, so an override that passes it positionally raises TypeError.
  • mrp.bom.consumption is 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 error

On 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:

  1. A static verifier that resolves every ref= in the module's XML, evaluates every view xpath against the runtime architecture (the fully inherited view, not the base file) using lxml, and checks every Python field reference against the 20.0 tree.
  2. 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.
  3. 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

  1. For every module, ask whether 20.0 core already does what it does. Retire before you port.
  2. Run Odoo's upgrade_code scripts, then convert security to ir.access.csv. No model may be left with only group-less rows.
  3. Search for .value / and value / in stock and valuation code; use _get_valued_qty(signed=True).
  4. Validate every set or list of field names against _fields.
  5. Replace every fa- icon and click every custom button.
  6. List overrides with no parent method on 20.0 and find what replaced each one.
  7. Capture expected values from the live 19.0 database before cut-over, read-only.

The rest of the series

For querying the ORM from outside Odoo, start with Odoo search_read, in depth and the ODXProxy API reference.