All posts
Developer12 min read

Odoo 18 for module developers: what changed and how to migrate from Odoo 17

Odoo 18 is the current LTS. If you are still running custom modules on 17, here is the concrete list of what breaks, what the migration script cannot do for you, and the order to tackle the changes.

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.module records 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):

python
def name_get(self):
    result = []
    for record in self:
        name = f"{record.ref}  -  {record.name}"
        result.append((record.id, name))
    return result

After (Odoo 18):

python
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:

python
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:

xml
<!-- 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:

xml
<!-- 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:

bash
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:

xml
<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 oe_button_box class still renders but uses legacy CSS. The new pattern wraps stat buttons in a

element that Odoo 18's UI picks up correctly. Replace:

xml
<div class="oe_button_box" name="button_box">

with:

xml
<div name="button_box">

JavaScript / OWL changes#

If your module ships custom OWL components (not legacy widget JS), the main change in 18 is the component lifecycle:

  • 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/owl

Legacy widget JS (AbstractField, 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#

If you have 10+ custom modules with inter-dependencies, this is the order that causes the least confusion:

  1. Run the module in a throwaway 18 database. Let it crash. Read the first error. Fix it. Repeat. This is faster than reading migration notes cover to cover.
  2. Fix Python errors first. They prevent the module from loading at all, which hides XML errors.
  3. Fix XML errors second. View validation errors appear after the module loads.
  4. Fix JS errors last. They only appear when the view actually renders in a browser.
  5. Run your test suite after each fix pass. If you do not have a test suite, the migration is your testing - budget accordingly.

The things the migration script will not tell you#

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 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.

Multi-company record rules that relied on implicit defaults. Odoo 18 changed the default behavior of 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#

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.

Get started free