Odoo 18 became the current long-term support release in October 2024. If your production instance is still on 17 - or if you are maintaining custom modules that need to run on 18 - this is the developer-focused migration reference you are probably looking for.
The official upgrade guide covers the user-visible changes well. This post covers the things that break in custom Python and XML when you run odoo-bin -u your_module on an 18 database for the first time.
What the Odoo migration script handles automatically#
Before listing what breaks, it is worth being clear about what the migration script (odoo-bin) handles for you:
- Standard model data: field values, many2one references to core models, stored computed field recomputation.
- Standard view updates: Odoo's own views, menus, and actions are updated to 18 conventions.
- Module state:
ir.module.modulerecords are updated for renamed or merged modules.
What it does not handle: your custom Python code, your custom XML, your custom JavaScript. Those are yours to fix.
The Python changes you will hit first#
1. _name_get is now _compute_display_name#
The most common breakage. name_get() was deprecated in 17 but still worked. In 18 it is gone. Any custom model that overrides name_get must be migrated.
Before (Odoo 17 and earlier):
def name_get(self):
result = []
for record in self:
name = f"{record.ref} - {record.name}"
result.append((record.id, name))
return resultAfter (Odoo 18):
def _compute_display_name(self):
for record in self:
record.display_name = f"{record.ref} - {record.name}"The display_name field is a stored computed field defined on BaseModel. You override the compute method. You do not need to declare the field.
Search implication. If you had a name_search override that used name_get's results, migrate it to _name_search (also renamed in 18) or use domain-based search on display_name.
2. @api.multi is gone#
This was deprecated since Odoo 13 but some legacy modules still use it. In 18 it raises AttributeError on import. Remove it. Every method in Odoo 18 is implicitly multi-record unless decorated with @api.model or @api.model_create_multi.
3. fields.Datetime.now() vs fields.Datetime.context_today()#
Odoo 18 tightened the timezone handling. fields.Datetime.now() returns a UTC datetime. fields.Date.context_today(self) returns the current date in the user's timezone. Using datetime.now() from the stdlib returns a naive datetime that can produce off-by-one errors on date fields. Use Odoo's helpers consistently.
4. sudo() propagation changes#
In 18, sudo() no longer propagates across mapped() and certain search_count() calls the same way it did in 17. If you have code like:
records = self.sudo().search([...])
count = records.mapped('child_ids').filtered(...)The filtered() call runs without sudo in 18. Make the sudo explicit on the result if you need it.
5. _inherit vs _name on abstract models#
Odoo 18 enforces that abstract models (_abstract = True) do not set _name to a dotted name they also inherit from. This has always been semantically wrong but 17 let it silently pass. You will see TypeError on module load if your abstract mixin has both _name and _inherit pointing to the same model.
The XML view changes#
OWL-era attribute syntax is required#
Odoo 18 does not load views that use the deprecated attrs attribute. This was deprecated in 16, still loaded in 17, and is now rejected at view validation time.
Every invisible, readonly, and required condition must use the modern syntax:
<!-- Old (does not load in 18) -->
<field name="due_date" attrs="{'invisible': [('state', 'in', ['draft', 'cancel'])]}"/>
<!-- New -->
<field name="due_date" invisible="state in ('draft', 'cancel')"/>The new syntax uses Python-like expressions directly on the attribute. Complex conditions use standard Python boolean operators. The domain list syntax is gone.
Similarly for states:
<!-- Old -->
<button name="action_approve" states="draft"/>
<!-- New -->
<button name="action_approve" invisible="state != 'draft'"/>How to find all instances in your codebase. Run this from your module root:
grep -rn "attrs=" --include="*.xml" .
grep -rn " states=" --include="*.xml" .Both greps together give you the full migration list. There is no automated converter - each one requires a human to read the condition and rewrite it.
is required in form views#
In 18, form views without a wrapper produce a deprecation warning that will become an error in 18.1. Wrap your form view content in if it is not already:
<form>
<sheet>
<group>
<field name="name"/>
</group>
</sheet>
</form>class="oe_button_box" on stat buttons#
The stat button container class changed in 18. The old with: If your module ships custom OWL components (not legacy widget JS), the main change in 18 is the component lifecycle: Legacy widget JS ( If you have 10+ custom modules with inter-dependencies, this is the order that causes the least confusion: Two categories of breakage are silent: Stored compute fields that changed logic. If you inherited a core Odoo field and the compute logic changed in 18, your stored values on existing records are wrong and the migration script does not recompute them. You need a Multi-company record rules that relied on implicit defaults. Odoo 18 changed the default behavior of The fastest way to scope your migration is to run the module on an 18 test database, capture every error and warning in the log, categorize by type (Python / XML / JS), and estimate hours per category. Most modules of 2,000–5,000 lines take one to three days of developer time to migrate cleanly. The first module is always the hardest because you are also learning the pattern changes. Every subsequent module is faster. Try ERPeek on your own Odoo module - ask questions, scaffold tests, and explore your codebase in plain language. A practical guide to Odoo 16+ REST API - how to authenticate with API keys, call model endpoints, filter records, and handle pagination without XML-RPC. A field-by-field breakdown of the ORM changes, view syntax shifts, and JavaScript regressions that hit most custom modules when upgrading from Odoo 17 to 18.oe_button_box class still renders but uses legacy CSS. The new pattern wraps stat buttons in a <div class="oe_button_box" name="button_box"><div name="button_box">JavaScript / OWL changes#
willStart is replaced by setup (which was already the 17 way, but willStart silently still ran in 17)patched and willPatch hooks now require explicit registration with onPatched and onWillPatch from @odoo/owlAbstractField, the Backbone-era widgets) does not run in Odoo 18. If you have those, the migration path is OWL. There is no shim.Migration order for a multi-module codebase#
The things the migration script will not tell you#
post-migrate.py script in your module's migrations/18.0.x.x.x/ directory that calls env['your.model']._compute_your_field() on the affected records.active_test in certain multi-company search paths. If your record rules did not explicitly set domain_force with a company filter, you may find records leaking across companies in edge cases. Audit your ir.rule records.Practical next step#
Related articles
Using the Odoo REST API: endpoints, authentication, and CRUD operations
Migrating Odoo modules from 17 to 18: what breaks and how to fix it

