Version 20 of Odoo was released in September 2026, and the most frequent question asked by developers right off the bat is the following: how many lines of my custom module do I have to rewrite manually? Not as many as you expect, provided that you utilize the tool provided by Odoo for the purpose. The command odoo-bin upgrade_code parses your source code and rewrites all the boilerplate code, like <tree> tags, old-school SQL constraints, and OWL 2 components, letting you focus on the actual work.
This blog post will take you through the process of using the tool with your custom module and explain what it does to the code and what it leaves intact.
What is upgrade_code?
Upgrade_code is a sub-command of odoo-bin. It uses a collection of smaller Python scripts stored in the odoo/upgrade_code directory in the Odoo source code and applies them to your addons directory. Each of the scripts performs one kind of code migration or rewrite. The official Odoo documentation calls it a code migration tool for all major refactoring and porting and is clear about the fact that each of these scripts is a best effort, but not a magic wand.
Before you start
Odoo 20 requires Python 3.12+ and PostgreSQL 16+, so start by checking that your server meets these requirements. Next, prepare the environment:
- Download or clone the Odoo 20 source. As the scripts operate inside it, the utility is as up-to-date as your version of checkout.
- Place the custom module into Git and commit it in its current state on a new branch, such as migration/20.0. The utility modifies the files directly, and therefore a clean copy is the best way to examine changes and roll back if necessary.
- Set up the virtual environment using Python 3.12 and install Odoo 20 dependencies.
The command and its options
Run the command from the root of your Odoo 20 source:
./odoo-bin upgrade_code --help
These are the options you will use:
usage: odoo-bin [--addons-path=PATH,...] upgrade_code [-h] (--script NAME | --from VERSION) [--to VERSION] [--glob GLOB] [--dry-run] [--addons-path PATH,...]
Rewrite the entire source code using the scripts found at /odoo/upgrade_code
options:
-h, --help show this help message and exit
--script NAME run this single script
--from VERSION run all scripts starting from this version, inclusive
--to VERSION run all scripts until this version, inclusive (default: 20.0)
--glob GLOB select the files to rewrite (default: **/*)
--dry-run list the files that would be re-written, but rewrite none
--addons-path PATH,...specify additional addons paths (separated by commas)
You have to give it either --script or --from. If you leave out --to, it defaults to the version of the Odoo you are running, which is what you want when migrating to 20.
Script names start with the Odoo version they belong to, followed by a number and a short name. For example, 17.5-01-tree-to-list is the script that turns <tree> into <list>, and 19.4-00-ir-access handles the move to the new access rule format. The exact set depends on the Odoo 20 build you downloaded, so list the folder to see what you actually have:
ls odoo/upgrade_code/
odoo/upgrade_code folder listing:

Step 1: Do a dry run
Start with --dry-run. Nothing gets written, and you get a list of the files the scripts would touch. Here, I am taking an example of a custom module contacts_mass_update which is in 19.0.
./odoo-bin upgrade_code \
--addons-path /home/cybrosys/odoo20/custom_addons \
--from 19.0 \
--glob contacts_mass_update/**/*' \
--dry-run
The --glob is important. Without it the tool looks at every file in the addons path, and you may not want it anywhere near a third-party module you don't maintain. Pointing the glob at one module keeps the run small and the diff readable.
If you are coming from an older version, change --from. A module written for Odoo 17 would use --from 17.0, which runs the scripts for 17, 18 and 19 on the way to 20. That saves you some work, but you will still be fixing 18, 19 and 20 API changes by hand afterwards.
dry run output listing the files that would be rewritten:

Step 2: Try one script first
Running everything at once is fine for a small module. For a bigger one, I would run scripts one at a time and commit after each. If something breaks, you know which script did it.
Here, I am taking estate_management, which is in version 17:
./odoo-bin upgrade_code \
--addons-path /home/cybrosys/odoo20/custom_addons \
--script 17.5-01-tree-to-list \
--glob estate_management/**/*'
After the run, check what changed:
git diff --stat
git diff
In a view file, the result looks like this.
Before:
<record id="estate_property_tree" model="ir.ui.view">
<field name="name">estate_management.property.tree</field>
<field name="model">estate_management.property</field>
<field name="arch" type="xml">
<tree string="Estate Properties">
<field name="name"/>
<field name="property_type"/>
<field name="price"/>
<field name="state"/>
</tree>
</field>
</record>
After:
<record id="estate_property_tree" model="ir.ui.view">
<field name="name">estate_management.property.tree</field>
<field name="model">estate_management.property</field>
<field name="arch" type="xml">
<list string="Estate Properties">
<field name="name"/>
<field name="property_type"/>
<field name="price"/>
<field name="bedrooms"/>
<field name="state"/>
</list>
</field>
</record>
The view_mode on your window actions gets the same treatment, so the tree form becomes the list form.
Commit it before moving on:
git commit -am "[MIG] estate_management: tree to list"
Step 3: Run the full range
Once the single scripts look sane, drop --script and run the whole range for your module:
./odoo-bin upgrade_code \
--addons-path /home/cybrosys/odoo20/custom_addons \
--from 17.0 \
--glob estate_management/**/*'
Depending on the module, you will see changes like these:
- tags renamed to
in views and view_mode values.
- _sql_constraints lists are converted to the newer constraint style. If you are coming from 18 or earlier, this is one you will notice: _sql_constraints = [('name_uniq', 'unique(name)', 'Name must be unique!')] becomes _name_uniq = models.Constraint('unique(name)', 'Name must be unique!').
- Access rules moved to the new ir.access format.
- Deprecated properties and dynamic date domains updated.
- OWL 2 components moved over to OWL 3.
File by file, open the diff and read it. Do not skip this because the scripts are "official". Odoo itself says they are best effort.
Difference after the full run:

If a script fails
The script will end with a traceback, and the most common reason for that is an unparseable file, usually an XML file with some error. People faced this problem with the tree-to-list script in Odoo 18. The traceback includes the name of the file, which means opening the file, correcting the XML and running the command once more. This is another argument in favor of executing the scripts one by one.
What upgrade_code will not do
The scripts rewrite syntax, and that is where they stop. Plan to handle these yourself:
- The business logic implemented in the overridden create(), write() and computed methods. The code executes but is incorrect.
- Outdated views regarding the usage of XPath in elements that have been either migrated or deleted.
- Anything that depends on Model._table_query, Odoo 20 will eliminate it. Such a model in a report that depends on this needs to be rethought.
- Binary fields, where Odoo 20 changes the RPC representation. You need to test any code that uses the exchange of documents.
- Renaming the models, fields, and XML IDs in the existing database. There must be upgrade scripts for these.
Before porting to the new version, it would be useful to verify if it already provides the same functionality. Removing a custom solution is easier than its migration.
Step 4: Install and test on an empty Odoo 20 database
Having finished reviewing the code, make sure the module operates on an empty database prior to working with the production one:
./odoo-bin -d test_20 -i my_module --test-enable --stop-after-init
Review the log starting from the beginning. Missing fields, unloading pages and deprecation warnings will appear at the beginning of the log. Remove the problem from the source, commit and build until the log looks fine. Next, review the most important workflows in the browser window, keeping an eye on the JavaScript errors in the console. Sometimes it is possible that the module gets installed well, but its OWL component has some problems.
Step 5: Data management
If you have added any field names or models in your module, or rearranged something in the process of development, be sure to add an upgrade script. The scripts should be stored in my_module/migrations/ or my_module/upgrades/, in a folder that relates to the version of your module, with prefixes pre-, post- or end-. The upgrade script has a migrate (cr version) function. In addition, there are special Odoo Upgrade Utils functions, such as util.rename_field() which allows to change the references that could not be changed just by renaming the database column. Remember to test everything in the production environment backup and neutralize it afterwards.
Think of odoo-bin upgrade_code as a quick first pass. It handles the boring rewrites in a few seconds and gives you a diff to review, which leaves your time for the code that needs thought. Run it on a clean Git branch, start with a dry run, keep the glob tight, and read every change before you commit it.
To read more about How to Migrate Custom Modules from Odoo 19 to Odoo 20, refer to our blog, How to Migrate Custom Modules from Odoo 19 to Odoo 20.