0% found this document useful (0 votes)
1 views28 pages

Odoo-Module-Migration-Guide

This technical guide by Tittu Thomas provides a comprehensive method for migrating custom Odoo modules from versions 15 to 19, aimed at Odoo developers and technical consultants. It outlines the challenges posed by annual releases, the importance of a structured migration process, and offers practical advice on preparing, executing, and verifying migrations. The guide emphasizes the need for careful planning and testing to avoid common pitfalls and ensure successful module updates.

Uploaded by

Alex T Thomas
Copyright
© All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
1 views28 pages

Odoo-Module-Migration-Guide

This technical guide by Tittu Thomas provides a comprehensive method for migrating custom Odoo modules from versions 15 to 19, aimed at Odoo developers and technical consultants. It outlines the challenges posed by annual releases, the importance of a structured migration process, and offers practical advice on preparing, executing, and verifying migrations. The guide emphasizes the need for careful planning and testing to avoid common pitfalls and ensure successful module updates.

Uploaded by

Alex T Thomas
Copyright
© All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd

TECHNICAL GUIDE

Migrating Odoo Custom Modules


Across Major Versions
A practitioner's method for moving custom addons from Odoo 15 through Odoo 19
without losing a weekend

BY

Tittu Thomas

Edition 1.0
Applies to Odoo Community and Enterprise, versions 15.0 through 19.0
Audience: Odoo developers and technical consultants maintaining custom addons

MIGRATING ODOO CUSTOM MODULES ACROSS MAJOR VERSIONS


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

Contents
About This Guide . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3

What this guide assumes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3

What this guide will not do . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3

The Shape of an Odoo Migration . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4

Why annual releases break addons . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4

The one-version-at-a-time rule . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .5

Where the time actually goes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5

Preparing: Work You Do Before Touching Code . . . . . . . . . . . . . . . . . . . . . . . . . 6

Build a reproducible environment first . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6

Take an inventory of your own module . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7

Decide the fate of each module before you start . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8

Baseline the current behaviour . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8

Migrating the Python Layer . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9

Fix the manifest first . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9

The ORM changes that matter most . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10

Direct SQL is your largest hidden risk . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12

Migrating Views and XML . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13

The conditional-attribute rewrite . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13

Automating the mechanical part . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13

List views and element naming . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15

QWeb output escaping . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15

Inherited views and xpath fragility . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15

The Front End . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17

Why this is the expensive part . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17

A triage order for front-end code . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17

Asset declaration . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17

Verifying the front end actually loaded . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18

Data and Schema Migration . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19

Where migration scripts live . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19

Writing a migration script that will not lose data . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19

Recomputing stored computed fields . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21

Verification: How to Know You Are Done . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22

Migrating Odoo Custom Modules Across Major Versions 1


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

Verify version specifics against the code, not a guide . . . . . . . . . . . . . . . . . . . . . . . . . . 22

Install, upgrade, and read the log . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22

Test behaviour, not just loading . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23

Exercise the user interface . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24

Definition of done . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24

Working Practices That Reduce Next Year's Cost . . . . . . . . . . . . . . . . . . . . . . . 25

Keep the core-coupling surface small . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25

Write the tests you will want next year . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25

Track deferred work explicitly . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25

Consider whether you should own the module at all . . . . . . . . . . . . . . . . . . . . . . . . . . . 25

Quick Reference . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26

Command reference . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26

Audit grep reference . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26

Migration order checklist . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26

Escalation: what to do when stuck . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27

Migrating Odoo Custom Modules Across Major Versions 2


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

About This Guide


Odoo ships a major version every year. Each release moves the ORM, the view engine, and the
JavaScript framework forward, and each one quietly breaks a slice of every custom addon in the wild. If
you maintain custom modules, you inherit a migration project on an annual cadence whether you
planned for one or not.

Most migration advice you find is a flat list of renamed methods. That list is useful for about an hour, and
then you hit the part nobody wrote down: the order you should do things in, how to tell a real breakage
from a deprecation warning you can ignore for another year, and how to know when you are actually
finished.

This guide is about the method. It gives you a sequence, a triage rule for every error class you will meet,
a reference of the changes that bite hardest, and a definition of done that does not rely on hope.

What this guide assumes


You have written or maintained an Odoo module. You can read Python and XML, you know what a
manifest file is, and you have at some point run Odoo with -u and watched it fail. You do not need to be
an ORM expert; the parts of the ORM that matter for migration are explained where they come up.

What this guide will not do


It will not promise that a mechanical tool can migrate your module. Tools handle the boring 60 percent.
The remaining 40 percent is judgement about business logic, and that part is yours.

It also will not pretend to be a substitute for the official release notes of your specific target version.
Odoo's internals shift between point releases, and the authoritative statement of what changed is
always Odoo's own changelog plus the commit history of the module you are extending. Where a detail
in this guide is version-sensitive, I say so and tell you how to verify it against your actual installation.
Treat this guide as the map and the release notes as the terrain.

A note on version specifics. Every version-delta table in this guide is a starting hypothesis, not gospel.
Before you rely on any single row, confirm it against the target codebase using the introspection recipes in
the chapter on verification. This takes two minutes and it has saved me from confidently shipping a fix for a
problem that did not exist.

Migrating Odoo Custom Modules Across Major Versions 3


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

The Shape of an Odoo Migration

Why annual releases break addons


Odoo's core is not a stable API in the sense that a library with a semantic-versioning contract is stable. It
is an application framework that the vendor also uses as their own product surface. When Odoo's own
developers need a better way to express a conditional field, they change the view parser and update the
thousand views in the standard addons in the same commit. Your module is not in that commit.

That produces four distinct categories of breakage, and separating them is the single most useful thing
you can do before writing any code.

Category 1: Hard removals


A method, field, or XML attribute no longer exists. The module fails to install, or it raises at runtime the
first time that code path executes. These are loud, and loud is good. You find them by trying to install.

Category 2: Silent semantic changes


The symbol still exists and your code still runs, but it now means something slightly different. A default
changed, an ordering guarantee disappeared, a method that used to return a list now returns a
recordset. These are the dangerous ones. Nothing fails; the numbers on a report are just wrong. You
find these only by testing behaviour, never by reading a stack trace.

Category 3: Deprecations with a grace period


Odoo frequently supports the old spelling for one or two versions while warning in the log. You can defer
these, but only deliberately, with a note in your issue tracker. Deferred deprecations are how a
one-version migration becomes a three-version rewrite.

Category 4: Structural shifts


The framework's model for doing something changed, not just its name. The move of the web client to
Owl is the canonical example: there is no mechanical translation from an old widget to an Owl
component, because the underlying concepts differ.

Category How you find it Typical cost Can you defer?

Hard removal Install fails, traceback Low per item, high in No


volume

Silent semantic change Behavioural testing only High, and easy to miss No, and you may not know it
exists

Deprecation with grace Log warnings during Low Yes, with a written note
period upgrade

Structural shift Feature simply does not Very high, often a rewrite Rarely
work

Migrating Odoo Custom Modules Across Major Versions 4


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

The one-version-at-a-time rule


If you are on 15 and you want 19, migrate to 16, then 17, then 18, then 19. Do not attempt 15 to 19 in a
single jump.

This feels wasteful. It is not, for three reasons.

First, error attribution. When you jump one version, every failure has one candidate cause: the changes
in that version. When you jump four, a traceback could originate in any of four sets of changes, and the
interactions between them produce failures that match nothing in any changelog.

Second, the standard addons are your reference implementation. At each step you can diff the
equivalent core module between the two adjacent versions and see exactly how Odoo's own developers
handled the same problem. That reference is only legible between adjacent versions.

Third, data migration. If your module owns data and the schema shifts, the intermediate states matter.
Odoo's own upgrade scripts run per version and assume they run in order.

The exception is a module with no data, no views, and no JavaScript — a pure utility of a few hundred lines.
Those you can often port directly. If you are not certain your module is in that category, it is not.

Where the time actually goes


From maintaining a mid-sized addon set across several versions, the rough distribution of effort looks
like this. Your numbers will differ, but the shape holds: the code you thought was the work is not the
work.

Activity Share of Notes


effort

Python model and ORM fixes 15 percent Highest volume, lowest difficulty. Tooling helps most here.

View and XML fixes 20 percent Volume plus fiddly. Attribute syntax churn dominates.

JavaScript and front-end 30 percent Lowest volume, highest difficulty. Often a rewrite.

Testing and behavioural verification 25 percent Always underestimated. This is where silent changes surface.

Data and schema migration scripts 10 percent Zero if your module owns no data; large if it does.

The lesson is that a migration estimate built by counting Python errors will be wrong by a factor of three
or more. Estimate from the front-end surface and the test burden instead.

Migrating Odoo Custom Modules Across Major Versions 5


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

Preparing: Work You Do Before Touching Code


Migrations fail more often from missing groundwork than from difficult code. This chapter is the
groundwork.

Build a reproducible environment first


You need to run the source version and the target version side by side, against copies of real data, with
the ability to reset to a known state in under a minute. Without this, every experiment costs you twenty
minutes of setup and you will stop experimenting.

Containers make this straightforward. The important properties are: pinned Odoo version, a separate
database per version, custom addons mounted rather than copied, and a filestore you can throw away.

# Two versions side by side, each with its own database.


# Addons are mounted read-only from a single working tree so a fix
# is visible to both containers without a rebuild.
services:
db15:
image: postgres:14
environment:
POSTGRES_USER: odoo
POSTGRES_PASSWORD: odoo
POSTGRES_DB: postgres
volumes: ["pg15:/var/lib/postgresql/data"]

odoo15:
image: odoo:15.0
depends_on: [db15]
ports: ["8015:8069"]
environment:
HOST: db15
USER: odoo
PASSWORD: odoo
volumes:
- ./addons:/mnt/extra-addons:ro
- ./conf/[Link]:/etc/odoo/[Link]:ro
- filestore15:/var/lib/odoo

db16:
image: postgres:14
environment:
POSTGRES_USER: odoo
POSTGRES_PASSWORD: odoo
POSTGRES_DB: postgres
volumes: ["pg16:/var/lib/postgresql/data"]

odoo16:
image: odoo:16.0
depends_on: [db16]

Migrating Odoo Custom Modules Across Major Versions 6


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

ports: ["8016:8069"]
environment:
HOST: db16
USER: odoo
PASSWORD: odoo
volumes:
- ./addons:/mnt/extra-addons:ro
- ./conf/[Link]:/etc/odoo/[Link]:ro
- filestore16:/var/lib/odoo

volumes:
pg15: {}
pg16: {}
filestore15: {}
filestore16: {}

Pin the minor image tag in anything you intend to keep. odoo:16.0 moves as Odoo publishes builds; if a
migration that worked last week fails today with no change on your side, an image that moved
underneath you is the first thing to check.

Take an inventory of your own module


You cannot migrate what you have not catalogued. Before starting, produce a written inventory of every
extension point your module touches. This document is what you will check against when you declare
the migration finished.

# Inherited models — every one is a coupling to core behaviour


grep -rn "_inherit\s*=" addons/ --include=*.py

# Overridden methods: the highest-risk surface in any addon,


# because core can change the signature underneath you
grep -rn "def \(create\|write\|unlink\|copy\|read\|search\|_search\|default_get\
|fields_get\|name_get\|name_search\|_compute_display_name\)\b" \
addons/ --include=*.py

# Inherited views, keyed by the core view they patch


grep -rn "inherit_id" addons/ --include=*.xml

# Direct SQL: bypasses the ORM, so no deprecation warning will ever


# tell you the underlying column moved
grep -rn "self\.\(env\.\)\?cr\.execute\|self\._cr\.execute" addons/ --include=*.py

# Front-end assets and the bundles they attach to


grep -rn "assets_backend\|assets_frontend\|web\.assets" addons/ --include=*.xml

# External dependencies that may not have a wheel for the new Python
grep -rn "external_dependencies" addons/ --include=__manifest__.py

Turn the output into a table with a row per item and columns for the target version, the status, and a
note. Sort by risk: direct SQL and overridden create/write at the top, simple field additions at the
bottom.

Migrating Odoo Custom Modules Across Major Versions 7


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

Decide the fate of each module before you start


Not every module deserves migration. For each one, pick a disposition and write it down:

1 Migrate as-is. The module still earns its keep and the logic is sound.
2 Migrate and simplify. Core has since absorbed part of what your module does. Delete your version
and keep the glue.
3 Replace with core configuration. The whole module is now a settings checkbox. This is the best
possible outcome and it happens more often than people check for.
4 Replace with a maintained community addon. Someone else now solves this and maintains it
across versions. Adopting it converts your annual migration cost into an occasional review.
5 Retire. Nobody has used the feature in two years. Confirm with the actual users, then delete.

Auditing for dispositions 3, 4, and 5 before writing code is the highest-leverage hour in the entire project. On
a set of a dozen custom modules, it is normal to retire two and reduce two more to configuration. That is a
third of the work gone before it started.

Baseline the current behaviour


You are about to change a system whose correct behaviour is defined by what it does today. Capture
that before you change it.

For each module, write down the three to five business outcomes that must remain identical: the total on
a specific report, the sequence a document number follows, the set of users who can see a given menu,
the value a computed field holds for a known record. Record actual values from the source version,
using real record IDs.

This list is your acceptance test. Without it, "it seems to work" is the best verdict you can reach, and it is
not good enough for anything that touches money.

Migrating Odoo Custom Modules Across Major Versions 8


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

Migrating the Python Layer


The Python layer is the highest-volume, lowest-difficulty part of the work. Get it done early to unblock
installation, because until the module installs you cannot test anything else.

Fix the manifest first


Nothing loads until the manifest is valid. The version string convention is the target Odoo series
followed by your own module version, so a module at 1.0.3 targeting Odoo 17 is [Link].3. Odoo
does not strictly enforce this, but every tool in the ecosystem assumes it, and getting it wrong makes
your module look like it targets a version it does not.

{
"name": "Delivery Route Planning",
# Series prefix must match the target Odoo. Bump your own trailing
# segments for real changes; the migration itself is a minor bump.
"version": "[Link].0",
"category": "Inventory/Delivery",
"license": "LGPL-3",
"author": "Your Organisation",
"website": "[Link]
"depends": [
"base",
"stock",
"sale_management",
],
"external_dependencies": {
# Verify each of these has a wheel for the Python version
# your target Odoo runs on before you commit to it.
"python": ["polyline"],
},
"data": [
# Security first: groups before the rules that reference them,
# rules before the views whose buttons those rules govern.
"security/route_groups.xml",
"security/[Link]",
"security/route_rules.xml",
"data/route_sequences.xml",
"views/route_views.xml",
"views/route_menus.xml",
"report/route_manifest_report.xml",
],
"assets": {
"web.assets_backend": [
"delivery_route/static/src/**/*",
],
},
"installable": True,
"application": False,
"auto_install": False,
}

Three manifest details cause disproportionate pain:

• Data file order is load order. A record that references an xml_id defined later in the list fails.
Security groups, then access rules, then data, then views.
• external_dependencies is checked at install, not at import. A missing wheel produces a
confusing install-time error rather than an ImportError where you would expect it. Check availability
against the target's Python version before committing.

Migrating Odoo Custom Modules Across Major Versions 9


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

• The assets key replaced the old approach of inheriting an assets template in XML. If your
module still declares front-end files through a QWeb template that inherits an assets bundle, that is a
structural change, not a rename. Move the declarations into the manifest.

The ORM changes that matter most

Display-name computation
The long-standing name_get method, which returned a list of (id, name) tuples, has been superseded
by a computed display_name field. The modern form is a compute method, which means it participates
in the ORM's dependency tracking and cache like any other computed field — a genuine improvement,
because the old method was invisible to invalidation.

# Older form: a method returning tuples, invisible to the ORM cache


def name_get(self):
result = []
for record in self:
name = f"[{[Link]}] {record.partner_id.name}"
[Link](([Link], name))
return result

# Modern form: a computed field, cached and invalidated correctly


@[Link]("code", "partner_id.name")
def _compute_display_name(self):
for record in self:
record.display_name = f"[{[Link]}] {record.partner_id.name}"

Note the @[Link]. Getting the dependency list wrong here produces a stale display name that
refreshes only when something unrelated touches the record — a textbook silent semantic change. If
your old name_get read a field through more than one relational hop, spell out the whole path.

If you must support two versions from one branch during a transition, keep both and have the legacy
method delegate:

def name_get(self):
# Transitional shim. Delete once every deployment is on the
# version where _compute_display_name is the only path.
return [([Link], record.display_name) for record in self]

Search-method signatures
name_search and its underscore-prefixed counterpart have had signature and return-type adjustments
across recent versions. The safe pattern is to never assume, and to introspect the signature in the
version you are targeting:

import inspect
from odoo import models

# Run this in an Odoo shell against the target version


print([Link]([Link]._name_search))
print([Link]([Link].name_search))

Two minutes of introspection beats any table in any guide, including this one. Do this for every core
method your module overrides. The inventory you built earlier tells you which ones those are.

Migrating Odoo Custom Modules Across Major Versions 10


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

Overriding create
The multi-record create with @api.model_create_multi is the correct modern form. A single-record
create override still works in many versions but is a performance trap: it defeats batch creation, so an
import of ten thousand records executes ten thousand separate flushes.

from odoo import api, fields, models

class DeliveryRoute([Link]):
_name = "[Link]"
_description = "Delivery Route"
_order = "scheduled_date desc, id desc"

name = [Link](required=True, copy=False, default="New", index=True)


scheduled_date = [Link](required=True, index=True)
partner_id = fields.Many2one("[Link]", string="Customer", index=True)
stop_ids = fields.One2many("[Link]", "route_id")
stop_count = [Link](compute="_compute_stop_count", store=True)

@[Link]("stop_ids")
def _compute_stop_count(self):
# read_group in one query instead of len() per record.
# On a list view of 500 routes this is the difference between
# one query and five hundred.
grouped = [Link]["[Link]"].read_group(
domain=[("route_id", "in", [Link])],
fields=["route_id"],
groupby=["route_id"],
)
counts = {g["route_id"][0]: g["route_id_count"] for g in grouped}
for route in self:
route.stop_count = [Link]([Link], 0)

@api.model_create_multi
def create(self, vals_list):
# Assign sequence numbers for the whole batch before delegating,
# so a 10k-record import stays a single ORM round trip.
for vals in vals_list:
if [Link]("name", "New") == "New":
vals["name"] = [Link]["[Link]"].next_by_code(
"[Link]"
) or "New"
return super().create(vals_list)

Translated string handling


The lazy-translation helper and the plain translation function have converged over recent versions, and
the module-level import style that once worked can produce strings translated at import time rather than
at render time — meaning every user sees the language of whichever request happened to load the
module. Prefer the standard import and let Odoo handle laziness:

Migrating Odoo Custom Modules Across Major Versions 11


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

from odoo import _, api, fields, models


from [Link] import UserError, ValidationError

def _check_capacity(self):
if self.stop_count > self.vehicle_id.max_stops:
# Interpolate through the translation call so translators see
# the placeholders, not a pre-formatted string.
raise ValidationError(_(
"Route %(route)s has %(count)s stops but vehicle %(vehicle)s "
"allows at most %(limit)s.",
route=self.display_name,
count=self.stop_count,
vehicle=self.vehicle_id.display_name,
limit=self.vehicle_id.max_stops,
))

Never build a translatable string with an f-string. The extractor stores the already-interpolated text, so
the entry never matches at runtime and the translation silently does nothing.

Direct SQL is your largest hidden risk


Every [Link] in your codebase is a bet that a table and column name will not change. The ORM
gives you deprecation warnings; PostgreSQL gives you a column-does-not-exist error at runtime, in
production, on the one code path nobody tested.

Audit every occurrence. For each, decide:

• Can it be ORM? Most read-side SQL written for speed can be a read_group or a search_read with
an appropriate domain. The ORM has improved considerably; benchmark before assuming you
need raw SQL.
• If it must stay, does it reference stable schema? Core tables like res_partner are relatively
stable. Columns on frequently reworked models are not.
• Is it covered by a test? Raw SQL with no test is the highest-risk construct in an Odoo addon. If you
keep it, write the test in the same commit.

# Prefer this: the ORM tracks schema changes for you


totals = [Link]["[Link]"].read_group(
domain=[("route_id.state", "=", "done"),
("delivered_on", ">=", period_start)],
fields=["weight_kg:sum", "route_id"],
groupby=["route_id"],
)

# Over this: fast, but silently coupled to physical column names


[Link]("""
SELECT route_id, SUM(weight_kg)
FROM delivery_route_stop s
JOIN delivery_route r ON [Link] = s.route_id
WHERE [Link] = 'done' AND s.delivered_on >= %s
GROUP BY route_id
""", (period_start,))

Migrating Odoo Custom Modules Across Major Versions 12


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

Migrating Views and XML

The conditional-attribute rewrite


The single largest volume of view churn in recent Odoo versions is the replacement of the packed attrs
dictionary — and the separate states attribute — with direct attributes holding Python expressions.
Instead of encoding conditions inside a dictionary keyed by property name, each property carries its
own condition.

<!-- Older form: conditions packed into a dict attribute -->


<field name="vehicle_id"
attrs="{'invisible': [('state', '=', 'draft')],
'required': [('state', '!=', 'draft')],
'readonly': [('state', 'in', ['done', 'cancel'])]}"/>
<button name="action_confirm" type="object" string="Confirm"
states="draft,waiting"/>

<!-- Modern form: one attribute per property, plain Python expression -->
<field name="vehicle_id"
invisible="state == 'draft'"
required="state != 'draft'"
readonly="state in ('done', 'cancel')"/>
<button name="action_confirm" type="object" string="Confirm"
invisible="state not in ('draft', 'waiting')"/>

Three things to watch during this rewrite.

The states inversion. The old states attribute listed the states in which an element was visible. The
replacement expresses when it is invisible. Forgetting to invert produces a button that appears in
exactly the wrong half of the workflow — and it will not error, so only a click-through finds it.

Every field in an expression must be in the view. The expression is evaluated client-side against the
loaded record. If you reference a field the view does not load, the condition silently evaluates against a
missing value. Add the field with column_invisible or as an invisible field so it is fetched.

Domains on domain attributes stay list-syntax. The change applies to conditional-property attributes.
A relational field's domain remains a domain expression. Do not convert those.

Automating the mechanical part


A well-tested regular expression handles simple single-key cases and leaves the rest for you. This is a
labour-saver, not an autopilot: run it, then read every hunk of the diff.

Migrating Odoo Custom Modules Across Major Versions 13


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

#!/usr/bin/env python3
"""Convert single-key attrs="{...}" to direct attributes.

Handles only the unambiguous single-key case. Multi-key attrs and


anything with a nested or non-trivial domain are left untouched and
reported, so you can convert them by hand with your eyes open.
"""
import re
import sys
from pathlib import Path

SIMPLE = [Link](
r"""attrs\s*=\s*"\{\s*'(invisible|readonly|required)'\s*:\s*"""
r"""\[\s*\(\s*'([\w.]+)'\s*,\s*'(=|!=|in|not\s+in)'\s*,\s*"""
r"""('[^']*'|\[[^\]]*\]|True|False)\s*\)\s*\]\s*\}\"""",
[Link],
)

OPS = {"=": "==", "!=": "!=", "in": "in", "not in": "not in"}

def convert(match):
prop, field, op, value = [Link]()
op = OPS[[Link](r"\s+", " ", op)]
if op in ("in", "not in") and [Link]("["):
value = "(" + value[1:-1] + ")"
return f'{prop}="{field} {op} {value}"'

def main(paths):
leftovers = 0
for path in paths:
for xml in Path(path).rglob("*.xml"):
original = xml.read_text(encoding="utf-8")
converted = [Link](convert, original)
if converted != original:
xml.write_text(converted, encoding="utf-8")
print(f"rewrote {xml}")
remaining = [Link]("attrs=")
if remaining:
leftovers += remaining
print(f" MANUAL: {remaining} attrs left in {xml}")
print(f"\n{leftovers} attrs need manual conversion")
return 1 if leftovers else 0

if __name__ == "__main__":
[Link](main([Link][1:] or ["."]))

Run this on a clean branch and review the entire diff before committing. A regular expression that rewrites
view logic deserves the same scrutiny as a hand-written change to view logic, because that is what it is.

Migrating Odoo Custom Modules Across Major Versions 14


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

List views and element naming


The element that renders a tabular view has been renamed from tree to list in recent versions, with
the old name accepted for a period. The view type string in actions and in [Link] records follows
the same shift. Because the old spelling often still works, this is easy to defer indefinitely and then
discover, two versions later, that a large number of files need touching at once.

Do the rename in one mechanical commit, separately from any logic change, so the diff stays
reviewable:

# Element name in view definitions


grep -rln "<tree" addons/ --include=*.xml

# View mode strings in window actions


grep -rn "view_mode.*tree" addons/ --include=*.xml

# Explicit view type on [Link] records


grep -rn "name=\"type\".*tree" addons/ --include=*.xml

Verify which spelling your target accepts before deciding, using the introspection recipe in the
verification chapter. Then commit the rename alone, with a message that says exactly that.

QWeb output escaping


The older output directive that escaped its content has been superseded by a directive with clearer
semantics around markup. The distinction matters for security: one escapes, the other can emit markup.
If you convert mechanically without checking each site, you can turn an escaped output into an
unescaped one and introduce a stored-cross-site-scripting hole in a report that renders user-supplied
text.

Convert deliberately. For each occurrence, ask whether the value is user-controlled, and if it is, confirm
the replacement still escapes it.

<!-- Escaped output of a user-controlled value -->


<span t-out="record.partner_id.name"/>

<!-- Deliberately unescaped markup. Only ever for values your own
code produced. Never for anything a user typed. -->
<div t-out="report_body_html"/>

Inherited views and xpath fragility


Your inherited views target core views by xml_id and locate elements by xpath. Both are unstable
across versions: core view ids get renamed, and the element structure they point into gets reorganised.

Make your xpaths as shallow and as semantic as you can. An xpath that names a field is far more
durable than one that counts divs.

Migrating Odoo Custom Modules Across Major Versions 15


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

<!-- Fragile: breaks the moment core adds a wrapper element -->
<xpath expr="/form/sheet/group/group[2]/div[1]/field[3]"
position="after">
<field name="route_priority"/>
</xpath>

<!-- Durable: survives restructuring as long as the field exists -->


<xpath expr="//field[@name='partner_id']" position="after">
<field name="route_priority"/>
</xpath>

<!-- Most durable: an explicit anchor, when core provides one -->
<xpath expr="//div[@name='delivery_options']" position="inside">
<field name="route_priority"/>
</xpath>

When a core view id disappears, find its replacement by searching the target version's source for the
view that renders the field you care about, rather than guessing at a renamed id.

Migrating Odoo Custom Modules Across Major Versions 16


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

The Front End

Why this is the expensive part


Odoo's web client moved to the Owl component framework, and this is a structural shift rather than a
rename. Older widget-based code, the legacy widget registry, and the imperative DOM manipulation
that went with them do not translate mechanically into components with declarative templates and
reactive state. There is no script for this. There is only understanding what your widget did and
rebuilding it.

Budget accordingly. A few hundred lines of custom JavaScript can easily outweigh several thousand
lines of Python in migration effort.

A triage order for front-end code


Work through your JavaScript in this order, because the cheap wins genuinely are cheap and they
shrink the expensive pile:

1 Delete what is dead. Custom front-end code accretes. Some of it patches behaviour that core has
since fixed, and some of it serves a feature nobody uses. Check before porting.
2 Replace what core now provides. Widgets for common patterns — coloured badges, progress
indicators, formatted numeric displays, relational selectors — have steadily been absorbed into core
field widgets. Search the target version's widget registry before rebuilding your own.
3 Convert simple field widgets. A widget that renders one field's value with custom formatting maps
onto a small component fairly directly. Do these next to build fluency in the new idiom.
4 Rebuild complex views last. A custom view type or a widget with its own data-fetching lifecycle is a
rewrite. Save it for when you understand the framework, not before.

Asset declaration
Front-end files are declared in the manifest's assets key, against a named bundle. Getting the bundle
wrong means your code loads on the wrong pages, or not at all — and a file that never loads produces
no error, just a feature that does nothing.

"assets": {
# Back-office web client
"web.assets_backend": [
"delivery_route/static/src/components/**/*.js",
"delivery_route/static/src/components/**/*.xml",
"delivery_route/static/src/scss/route_board.scss",
],
# Public website and portal pages
"web.assets_frontend": [
"delivery_route/static/src/portal/route_tracking.js",
],
# Loaded only under the test runner
"web.assets_tests": [
"delivery_route/static/tests/**/*.js",
],
},

Migrating Odoo Custom Modules Across Major Versions 17


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

Bundle names change between versions. Confirm the current set by reading the manifest of a core
module that declares the kind of asset you are adding, in your exact target version. That is the
authoritative answer and it takes thirty seconds to look up.

Verifying the front end actually loaded


The most common front-end migration failure is not an exception. It is silence: the bundle did not include
your file, so nothing ran.

Check explicitly rather than inferring from behaviour:

• Load a page and confirm your file appears in the browser's network panel as part of the bundle
request.
• Confirm the component or patch registered, by inspecting the relevant registry from the browser
console.
• Only then test whether it behaves correctly.

Skipping the first two steps means every misconfigured asset path presents as a logic bug, and you will
debug logic that never executed.

Migrating Odoo Custom Modules Across Major Versions 18


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

Data and Schema Migration


If your module stores data, code changes are only half the job. This chapter is skippable for modules
that own no data — and if that is you, skip it, because migration scripts you do not need are pure risk.

Where migration scripts live


Odoo runs version-specific scripts from a migrations directory inside your module, keyed by the
module version they upgrade to, with pre- running before the schema update and post- after.

delivery_route/
migrations/
[Link].0/
[Link]
[Link]

The distinction matters:

• pre- runs before Odoo updates the schema. Use it to preserve data that the schema update would
destroy — a column you are about to rename or drop, for instance. Copy it somewhere safe.
• post- runs after the new schema exists. Use it to populate new columns, rebuild computed stores,
and clean up the temporary tables your pre- script created.

Writing a migration script that will not lose data

Migrating Odoo Custom Modules Across Major Versions 19


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

"""post-20: backfill route codes for records created before the field existed.

Idempotent by construction: re-running it does nothing. Batched so a


large table does not build one enormous transaction.
"""

def migrate(cr, version):


if not version:
# Fresh install, not an upgrade. There is nothing to backfill,
# and running anyway is how you corrupt a new database.
return

# Confirm the column exists before touching it. A migration that


# assumes schema state is a migration that fails on the one
# database whose history differs from yours.
[Link]("""
SELECT 1 FROM information_schema.columns
WHERE table_name = 'delivery_route' AND column_name = 'code'
""")
if not [Link]():
return

batch = 5000
while True:
[Link]("""
WITH target AS (
SELECT id FROM delivery_route
WHERE code IS NULL OR code = ''
ORDER BY id
LIMIT %s
)
UPDATE delivery_route r
SET code = 'RT-' || LPAD([Link]::text, 6, '0')
FROM target t
WHERE [Link] = [Link]
RETURNING [Link]
""", (batch,))
if not [Link]:
break

Four rules for migration scripts, each learned the hard way:

1 Guard on version. A falsy version means fresh install. Running backfill logic against a new
database is a classic way to produce data that should not exist.
2 Be idempotent. Upgrades get interrupted and re-run. A script that doubles a value on second
execution is a script that will eventually double a value.
3 Batch large updates. A single statement over millions of rows holds locks for the duration and can
exhaust transaction resources. Loop in bounded chunks.
4 Never assume schema state. Check that a table and column exist before referencing them. Real
databases have histories yours does not.

Migrating Odoo Custom Modules Across Major Versions 20


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

Recomputing stored computed fields


New or changed stored computed fields do not populate themselves for existing records. Trigger the
recomputation explicitly, in batches, from a post-migration script or an Odoo shell.

# In an Odoo shell against the upgraded database


records = env["[Link]"].with_context(active_test=False).search([])
total = len(records)
for offset in range(0, total, 1000):
chunk = records[offset:offset + 1000]
chunk._compute_stop_count()
chunk.flush_recordset()
[Link]()
print(f"{min(offset + 1000, total)}/{total}")

Note active_test=False. Archived records are excluded from a default search, and a stored field left
stale on archived records produces wrong totals the moment someone unarchives one or runs a report
that includes inactive records.

Migrating Odoo Custom Modules Across Major Versions 21


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

Verification: How to Know You Are Done


This is the chapter people skip, and skipping it is why migrations get declared finished twice.

Verify version specifics against the code, not a guide


Every version-sensitive claim in this document — and in any other migration guide — should be
checked against your actual target. Three techniques cover almost everything.

Introspect the Python API. An Odoo shell against the target version answers signature and existence
questions definitively:

import inspect
from odoo import models

Model = [Link]
print(hasattr(Model, "name_get"))
print([Link](Model._name_search))
print([Link](Model._compute_display_name))

Read the core addons. For any pattern you need — a view attribute, an asset bundle name, a widget
registration — find a core module that does the same thing in your target version and copy its approach.
Core is the reference implementation and it is always correct for that version.

Diff adjacent versions. With both versions' source available, diff the core module most similar to yours.
The changes Odoo's own developers made are the changes you need to make.

# What changed in a comparable core module between versions


diff -ru odoo-16.0/addons/stock_delivery odoo-17.0/addons/stock_delivery | less

# Every view file that stopped using the old conditional syntax
grep -rl "attrs=" odoo-16.0/addons/stock/views/
grep -rl "attrs=" odoo-17.0/addons/stock/views/

Install, upgrade, and read the log


The minimum bar is a clean install and a clean upgrade with no unexplained warnings.

# Fresh install into a new database


odoo -d test_fresh -i delivery_route --stop-after-init \
--log-level=warn --without-demo=all

# Upgrade over a restored copy of production data


odoo -d test_upgrade -u delivery_route --stop-after-init --log-level=warn

# Update everything: catches views your module breaks in other modules


odoo -d test_upgrade -u all --stop-after-init --log-level=warn

That last command matters more than it looks. A module can install cleanly on its own and still break a
core view through a bad xpath in an inherited view. Only a full update surfaces that.

Read the log rather than checking the exit code. Odoo logs a great many warnings that do not fail the
process and do indicate real problems — missing xml_ids, view fields that do not exist, deprecated
constructs. Grep for them:

Migrating Odoo Custom Modules Across Major Versions 22


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

odoo -d test_upgrade -u all --stop-after-init 2>&1 | tee [Link]


grep -iE "warning|error|deprecat|cannot|invalid|missing" [Link]

Test behaviour, not just loading


Installation proves your module parses. It proves nothing about correctness. Silent semantic changes —
Category 2 from the first chapter — are invisible to every check so far.

Go back to the baseline you captured before starting and verify each recorded outcome on the migrated
system, using the same records and the same inputs. Values must match, not merely look plausible.

Automate what you can:

from [Link] import TransactionCase, tagged

@tagged("post_install", "-at_install")
class TestRouteTotals(TransactionCase):
"""Lock in the behaviour that must survive migration.

Written against the pre-migration version, run unchanged against


the post-migration version. That is the whole point: the test is
the specification of what must not change.
"""

def setUp(self):
super().setUp()
[Link] = [Link]["[Link]"].create({"name": "Test Co"})
[Link] = [Link]["[Link]"].create({
"scheduled_date": "2026-01-15",
"partner_id": [Link],
})

def test_stop_count_tracks_stops(self):
[Link]["[Link]"].create([
{"route_id": [Link], "sequence": 1, "weight_kg": 12.5},
{"route_id": [Link], "sequence": 2, "weight_kg": 8.0},
])
[Link].invalidate_recordset(["stop_count"])
[Link]([Link].stop_count, 2)

def test_display_name_format_is_stable(self):
# Display-name format is depended on by exports and integrations,
# so a change here is a breaking change even though it is cosmetic.
[Link] = "RT-000123"
[Link].invalidate_recordset(["display_name"])
[Link]([Link].display_name, "[RT-000123] Test Co")

Tests written before the migration and run after it are the only mechanism that reliably catches silent
changes. Everything else depends on someone noticing that a number looks wrong.

Migrating Odoo Custom Modules Across Major Versions 23


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

Exercise the user interface


Automated tests do not open views. Some breakage exists only in rendering: a field referenced by a
conditional expression but not loaded, a button whose visibility condition inverted, a report whose layout
broke.

Walk every view your module adds or modifies, in a browser, as a user with realistic permissions:

• Open each list, form, kanban, and search view. Look for missing fields and empty widgets.
• Move a record through its whole workflow. Confirm each button appears exactly where it should —
this is where an un-inverted states conversion surfaces.
• Print every report. Check totals against the source system.
• Repeat as a restricted user. Access-rule and record-rule interactions change between versions, and
a rule that silently widened is a security regression.
• Check the browser console for errors on each page.

Definition of done
A migration is finished when all of the following hold. Anything less is a migration in progress.

Check Standard

Fresh install Clean, no warnings you cannot explain

Upgrade over production copy Clean, on a full-size restore, not a toy dataset

Full -u all Clean; no core view broken by your inherited views

Automated tests Pass, including tests written before migration

Baseline outcomes Every recorded value matches the source system

Every view Opened in a browser, no console errors

Every report Printed, totals verified against source

Restricted user Permissions behave as before, nothing widened

Deferred items Written down in the tracker, with a target version

Inventory Every row from the initial audit closed

That last row is the one that catches the module you forgot. The inventory exists so that "done" is a
checklist rather than a feeling.

Migrating Odoo Custom Modules Across Major Versions 24


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

Working Practices That Reduce Next Year's Cost


Migration cost is not fixed. It is a function of how your module is written, and you control that.

Keep the core-coupling surface small


Every inherited model, overridden method, xpath, and raw SQL statement is a coupling to core
internals, and each coupling is a thing that can break. Fewer couplings is less migration work, every
year, forever.

Practical habits:

• Prefer extension by addition. Adding a field and a compute method is far more durable than
overriding create to alter core behaviour.
• Override the narrowest hook available. If core provides a small hook method designed for
extension, use it instead of wrapping the large public method that calls it.
• Isolate coupling in one place. If you must depend on a fragile core detail, wrap it in a single
method of your own. When it breaks, you fix one function rather than fifteen call sites.
• Write xpaths against field names. Structural xpaths break on every reorganisation; semantic ones
survive.

Write the tests you will want next year


The tests that pay for themselves during migration are not unit tests of your own helpers. They are
behavioural tests that pin down business outcomes: this input produces this total, this sequence, this
permission result.

Write them at the boundary of your module's contract, in the language of the business rule rather than
the implementation. Those tests survive refactoring, they survive migration, and they are the only
automated defence against silent semantic changes.

Track deferred work explicitly


Every deprecation you choose not to fix is a decision. Record it: what the deprecation is, why you
deferred it, and the version by which it must be resolved. An untracked deferral is indistinguishable from
an oversight, and it will surface as an emergency two versions later.

Consider whether you should own the module at all


The cheapest module to migrate is the one you deleted. Each year, revisit dispositions 3, 4, and 5 from
the preparation chapter: has core absorbed this feature, does a maintained community addon now
cover it, has the requirement gone away?

A custom module is a recurring annual liability, not a one-time asset. That is not an argument against
writing them — it is an argument for periodically checking that each one still earns its keep.

Migrating Odoo Custom Modules Across Major Versions 25


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

Quick Reference

Command reference

# Install a module into a fresh database, no demo data


odoo -d DB -i MODULE --stop-after-init --without-demo=all

# Upgrade one module


odoo -d DB -u MODULE --stop-after-init

# Upgrade everything — the check that catches broken inherited views


odoo -d DB -u all --stop-after-init

# Interactive shell for introspection and data fixes


odoo shell -d DB

# Run a module's tests


odoo -d DB -i MODULE --test-enable --stop-after-init --log-level=test

# Run one test class by tag


odoo -d DB -u MODULE --test-tags /MODULE:TestClassName --stop-after-init

Audit grep reference

# Core coupling surface


grep -rn "_inherit\s*=" addons/ --include=*.py
grep -rn "inherit_id" addons/ --include=*.xml

# Overridden core methods, highest risk first


grep -rn "def \(create\|write\|unlink\|_search\|default_get\)\b" \
addons/ --include=*.py

# Raw SQL
grep -rn "cr\.execute" addons/ --include=*.py

# View constructs that churn between versions


grep -rn "attrs=" addons/ --include=*.xml
grep -rn "states=" addons/ --include=*.xml
grep -rn "<tree" addons/ --include=*.xml

# Legacy asset declaration through template inheritance


grep -rn "assets_backend\|assets_frontend" addons/ --include=*.xml

Migration order checklist


1 Inventory every coupling; decide each module's disposition.
2 Baseline the business outcomes that must not change, with real values.
3 Stand up source and target environments side by side.
4 Fix the manifest so the module loads at all.
5 Fix Python until installation is clean.
6 Fix views until every one opens.
7 Port or rebuild front-end code.
8 Write and test migration scripts against a full-size data restore.
9 Verify behaviour against the baseline, view by view and report by report.

Migrating Odoo Custom Modules Across Major Versions 26


Migrating Odoo Custom Modules Across Major Versions Tittu Thomas · Edition 1.0

10 Close every inventory row; record every deferral with a target version.

Escalation: what to do when stuck


When an error makes no sense, work through these in order. In my experience the answer is in the first
three about eighty percent of the time.

1 Read the whole traceback, including the Odoo frames. The interesting line is usually not the last
one.
2 Find the same pattern in core, in your exact target version, and compare against what you wrote.
3 Diff the comparable core module between source and target versions.
4 Introspect in the shell. Does the method exist? What is its signature? What does its source say?
5 Check the field is loaded if a view condition behaves oddly. This explains a surprising share of
view bugs.
6 Confirm the asset bundle loaded if front-end code appears not to run. Silence is the symptom of a
missing file, not a broken one.
7 Bisect your own diff. Revert to the last working state and reapply in halves.
8 Test against a full-size data restore. Some failures exist only at production scale or with
production data shapes.

Migrating Odoo Custom Modules Across Major Versions 27

You might also like