Skip to Content

Upgrading to Odoo 20: What We Learned Migrating a Custom Module from Odoo 16

What breaks, what Odoo's own upgrade scripts fix, and what still needs a careful pair of hands.

What breaks, what Odoo's own upgrade scripts fix, and what still needs a careful pair of hands.

Key takeaways

  • Going from Odoo 16 to Odoo 20 means catching up on four versions of changes (17, 18, 19 and 20) in your custom code.
  • In our 413-line sample module, 43 of the 48 issues found would stop it working on Odoo 20.
  • Odoo's own upgrade_code scripts made about a quarter of the needed changes, and one of their changes was wrong.
  • The riskiest changes are quiet ones: read_group() keeps its name with new arguments, and access rules move to the new ir.access model.
  • Scan first, run the scripts on a copy, test every module and rehearse the data migration before go-live.

Odoo 20 is here. If your company runs Odoo 16 with custom modules, the jump is not one upgrade but four: versions 17, 18, 19 and 20 each changed how custom code has to be written. A module built for Odoo 16 will not even install on Odoo 20 until those changes are made.

Rather than guess, we ran the whole process on a sample module: a typical Odoo 16 "Service Job Cards" app with models, views, access rules, a PDF report, a small dashboard and automated tests. All the data in it is test data. Here is what we found.

413lines of custom code
48issues found
43would break on Odoo 20
4 / 4tests passing after the port

What changed between Odoo 16 and Odoo 20

We wrote a scanner that checks custom modules for every breaking change we know of between the two versions, and ran it on the sample. These are the changes that matter most for custom code:

VersionWhat changedWhat to do
17attrs and states removed from views; name_get() and user_has_groups() goneWrite conditions as invisible="..." / readonly="..."; use _compute_display_name and has_group
17The JavaScript rpc service removedImport the rpc function instead
18<tree> views renamed, group_operator and groups_id renamed<list>, aggregator, group_ids
18JSON routes and chatter markup changedtype="jsonrpc", <chatter/>
19User groups reorganised into privileges; _sql_constraints replacedprivilege_id, user_ids, models.Constraint
19read_group() keeps its name but takes new arguments and returns tuplesRewrite every call by hand
20Access rights and record rules merged into one new model, ir.accessConvert, then re-check every rule
20odoo.osv.expression removedUse Domain objects
20The web client moved to OWL 3; t-esc removed from templatesNew component patterns, t-out

Odoo's own upgrade scripts: helpful, not finished

Odoo 20 ships a set of code-upgrade scripts (odoo-bin upgrade_code). On our sample they handled the repetitive renames well: list views, JSON routes, SQL constraints, the new access-rights file and part of the OWL 3 conversion. In total they made about a quarter of the 153 lines that had to change.

Three lessons from running them:

  • Check their output. The OWL 3 script imported a component that does not exist in the current Odoo 20 build. The dashboard would simply have failed to load. Only reviewing the diff and opening every screen catches that.
  • Run them on a copy, never on your server. Started through odoo-bin, the command also includes Odoo's own addons folders. In our first run it rewrote hundreds of Odoo's own source files as well as the custom module. We restored them from the original package, but on a production server that would be a serious problem.
  • Some scripts must be called by name. The OWL 3 conversion is not tied to a version number, so a "migrate from 16" run skips it.

The changes that need a person

The remaining three quarters were manual. Two examples show why.

View logic (Odoo 17)

Odoo 16
<button name="action_done" type="object"
        states="confirmed"/>
<field name="partner_id"
   attrs="{'readonly': [('state', 'in',
                         ('done', 'cancel'))]}"/>
Odoo 20
<button name="action_done" type="object"
        invisible="state != 'confirmed'"/>
<field name="partner_id"
   readonly="state in ('done', 'cancel')"/>

Business logic (Odoo 17, 19 and 20)

One small dashboard method needed three separate fixes. The most dangerous is read_group: the name is unchanged, so a search-and-replace finds nothing to fix, but the arguments and the result are completely different.

Odoo 16
from odoo.osv import expression

domain = [('state', '!=', 'cancel')]
if not self.user_has_groups('..group_job_manager'):
    domain = expression.AND([domain,
        [('technician_id', '=', self.env.uid)]])
groups = self.read_group(domain,
    ['amount_total:sum'], ['state'])
Odoo 20
from odoo.fields import Domain

domain = Domain('state', '!=', 'cancel')
if not self.env.user.has_group('..group_job_manager'):
    domain &= Domain('technician_id', '=',
                     self.env.uid)
groups = self.read_group(domain,
    ['state'], ['__count', 'amount_total:sum'])

Access rights (Odoo 20)

Odoo 20 merges model access lines and record rules into one ir.access model. The upgrade script converts them, but a mistake here does not show an error; it shows data to the wrong people. We added a test that logs in as a technician and checks they only see their own job cards. It passes on Odoo 20.

The result: running on Odoo 20

The migrated job card list view running on Odoo 20
The migrated list view on Odoo 20, with its original colour rules and totals. Test data.
The migrated job card form view on Odoo 20
The form view with rewritten button and field conditions and the new chatter.
The custom dashboard rebuilt for OWL 3 on Odoo 20
The custom dashboard, rebuilt for Odoo 20's OWL 3 web framework.
$ odoo-bin -i service_job_card --test-tags /service_job_card
Starting TestJobCard.test_cannot_close_empty_job ...
Starting TestJobCard.test_dashboard ...
Starting TestJobCard.test_sequence_and_total ...
Starting TestJobCard.test_technician_sees_only_own_jobs ...
0 failed, 0 error(s) of 4 tests

Code is half the job: your data

Your business data moves separately from your custom code, and both must be ready at the same moment.

  • Standard data (customers, sales, invoices, stock, accounting) goes through the official upgrade path: Odoo's upgrade service for Enterprise, or OpenUpgrade for Community. As of October 2026 the upgrade service's newest target is 19.0 and OpenUpgrade's 20.0 branch is still in progress, so check when Odoo 20 becomes available for your database and which apps are covered.
  • Custom data needs migration scripts inside each custom module for renamed fields, changed options and recomputed values.
  • Rehearse on a copy of your production database, time it, and compare record counts, open invoices, balances and stock before and after.

Before you upgrade: a short checklist

  • List every custom and third-party module, and check that the third-party ones exist for Odoo 20.
  • Scan the custom modules so you know the size of the job before you start.
  • Run Odoo's upgrade scripts on a copy, then review every change they make.
  • Add tests for each module's key flows and access rules.
  • Rehearse the data migration at least once on a copy of production.
  • Let your key users test their daily work before go-live, and keep a rollback plan.

Frequently asked questions

Can I upgrade directly from Odoo 16 to Odoo 20?

Not for an existing database yet. As of October 2026, Odoo's upgrade platform offers 19.0 as its newest target, and OpenUpgrade's 20.0 branch is still in progress. You can upgrade your data to Odoo 19 now and port your custom modules to Odoo 20 in parallel, so they are ready when Odoo 20 opens as an upgrade target. Either way, the modules must be updated for every change made in Odoo 17, 18, 19 and 20.

Will my custom Odoo modules work on Odoo 20 without changes?

Almost certainly not. In our Odoo 16 sample module, 43 of the 48 issues our scanner found would stop the module installing or working on Odoo 20, including view syntax, access rules and the web framework.

Can Odoo's upgrade_code scripts migrate my modules automatically?

Partly. In our test they made about a quarter of the changed lines, mostly renames, and one of their changes imported a component that does not exist in the current Odoo 20 build. Use them as a first step on a copy, then review and test everything.

How long does an Odoo 16 to Odoo 20 migration take?

It depends mainly on how many custom modules you have, how large they are and how much data you hold. Scanning the custom code at the start gives a reliable estimate before any migration work begins.

What is ir.access in Odoo 20?

It is the single model that replaces both model access rights (ir.model.access) and record rules (ir.rule). Records linked to a group grant access to that group, records without a group restrict everyone, and each record lists the operations it covers.

Planning your upgrade to Odoo 20?

We migrate Odoo systems with custom modules from older versions to Odoo 20: assessment, module porting, data migration, testing and go-live, with your team kept informed at every step. Tell us which version you are on and how many custom modules you have, and we will tell you what the upgrade involves.

Contact us about your upgrade

Still Running Your Business on Excel and WhatsApp?
Where to start with business digitization - and what to automate first.