Odoo-Module-Migration-Guide
Odoo-Module-Migration-Guide
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
Contents
About This Guide . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
Asset declaration . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
Definition of done . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24
Quick Reference . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26
Command reference . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26
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.
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.
That produces four distinct categories of breakage, and separating them is the single most useful thing
you can do before writing any code.
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
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.
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.
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.
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]
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.
# 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.
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.
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.
{
"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,
}
• 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.
• 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.
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.
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
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.
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.
class DeliveryRoute([Link]):
_name = "[Link]"
_description = "Delivery Route"
_order = "scheduled_date desc, id desc"
@[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)
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.
• 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.
<!-- 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')"/>
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.
#!/usr/bin/env python3
"""Convert single-key attrs="{...}" to direct attributes.
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.
Do the rename in one mechanical commit, separately from any logic change, so the diff stays
reviewable:
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.
Convert deliberately. For each occurrence, ask whether the value is user-controlled, and if it is, confirm
the replacement still escapes it.
<!-- Deliberately unescaped markup. Only ever for values your own
code produced. Never for anything a user typed. -->
<div t-out="report_body_html"/>
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.
<!-- 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>
<!-- 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.
Budget accordingly. A few hundred lines of custom JavaScript can easily outweigh several thousand
lines of Python in migration effort.
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",
],
},
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.
• 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.
delivery_route/
migrations/
[Link].0/
[Link]
[Link]
• 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.
"""post-20: backfill route codes for records created before the field existed.
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.
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.
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.
# 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/
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:
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.
@tagged("post_install", "-at_install")
class TestRouteTotals(TransactionCase):
"""Lock in the behaviour that must survive migration.
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.
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
Upgrade over production copy Clean, on a full-size restore, not a toy dataset
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.
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 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.
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.
Quick Reference
Command reference
# Raw SQL
grep -rn "cr\.execute" addons/ --include=*.py
10 Close every inventory row; record every deferral with a target version.
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.