All posts
Developer10 min read

OpenUpgrade Migration Toolchain for Custom Odoo Modules

Use the OpenUpgrade toolchain to write reliable migration scripts for custom Odoo modules - pre/post hooks, rename_columns, delete_model, module_installed checks, and data fixup patterns for each major version jump.

OpenUpgrade Migration Toolchain for Custom Odoo Modules#

What Is OpenUpgrade?#

OpenUpgrade is the open-source project that provides migration scripts for all standard Odoo modules when upgrading between major versions (e.g., 16 → 17). For custom modules, you write your own migrations/ scripts that follow the same conventions.

Migration Script Structure#

Place migration scripts inside your module:

my_module/
  migrations/
    17.0.1.0.0/
      pre-migration.py    ← runs BEFORE the module upgrade
      post-migration.py   ← runs AFTER the module upgrade
    16.0.1.0.0/          ← version-specific folder (source version, not target)
      ...

Odoo identifies which scripts to run by comparing the installed version stored in ir.module.module against the folder name.

Pre-Migration Script#

The pre-migration script runs before ORM is applied. Use raw SQL for safety:

python
# migrations/17.0.1.0.0/pre-migration.py
from openupgradelib import openupgrade

@openupgrade.migrate()
def migrate(env, version):
    if not version:
        return  # fresh install, skip

    # Rename a column that changed name in v17
    openupgrade.rename_columns(env.cr, {
        'my_model': [('old_field', 'new_field')],
    })

    # Convert a Selection field value that changed
    env.cr.execute("""
        UPDATE my_model
        SET state = 'confirmed'
        WHERE state = 'validate'
    """)

Post-Migration Script#

Post-migration runs after ORM is applied. Use the full Odoo ORM:

python
# migrations/17.0.1.0.0/post-migration.py
from openupgradelib import openupgrade

@openupgrade.migrate()
def migrate(env, version):
    if not version:
        return

    # Recompute a stored field that changed formula
    records = env['my.model'].search([])
    records._compute_total_amount()
    records.invalidate_recordset()

    # Migrate data to a new Many2many replacing old Char
    for rec in records:
        if rec.legacy_tag_text:
            tag = env['my.tag'].search([
                ('name', '=', rec.legacy_tag_text)
            ], limit=1)
            if not tag:
                tag = env['my.tag'].create({'name': rec.legacy_tag_text})
            rec.tag_ids = [(4, tag.id)]

openupgradelib Helpers#

The openupgradelib package provides utilities for common patterns:

python
from openupgradelib import openupgrade

# Rename model (updates ir.model, ir.model.fields, ir.rule, etc.)
openupgrade.rename_models(env.cr, [('old.model', 'new.model')])

# Rename XML ID
openupgrade.rename_xmlids(env.cr, [
    ('my_module.old_record_id', 'my_module.new_record_id'),
])

# Delete a model that no longer exists
openupgrade.delete_model_workflow(env.cr, 'my.obsolete.model')

# Check if a module is installed (use in conditionals)
if openupgrade.is_module_installed(env.cr, 'sale_management'):
    # do something only when sale is present
    pass

# Merge a field into another (copy data, then drop)
openupgrade.merge_columns(env.cr, 'sale_order', 'old_col', 'new_col')

Column and Table Safety#

Never drop columns or tables in pre-migration. Mark them for deletion instead:

python
# pre-migration: rename to _migrated suffix so ORM won't touch it
env.cr.execute("""
    ALTER TABLE my_model
    RENAME COLUMN deprecated_field TO deprecated_field_migrated;
""")

# post-migration: drop after data is no longer needed
env.cr.execute("""
    ALTER TABLE my_model DROP COLUMN IF EXISTS deprecated_field_migrated;
""")

Handling Many2many Table Renames#

When a Many2many relation table changes name:

python
# pre-migration.py
openupgrade.rename_tables(env.cr, [
    ('old_m2m_rel_table', 'new_m2m_rel_table'),
])

Version Guard Pattern#

Always guard your migration to skip fresh installs:

python
@openupgrade.migrate()
def migrate(env, version):
    if not version:
        # Module is being installed for the first time  -  no migration needed
        return
    # Migration logic here

Testing Your Migration#

  1. Clone the production database to a test instance.
  2. Run odoo -d testdb -u my_module --stop-after-init.
  3. Check logs for errors and warnings.
  4. Run your test suite against the migrated DB.
  5. Spot-check key records for data integrity.

Common Mistakes#

  • Missing @openupgrade.migrate() decorator - the script is silently skipped; always decorate the migrate() function
  • Using ORM in pre-migration - the ORM is not ready before the upgrade; stick to raw SQL in pre-migration
  • Not guarding against fresh installs - version is None on first install; always check if not version: return
  • Hardcoding database-specific IDs - never reference id values directly; use XML IDs or domain searches
  • Forgetting to install openupgradelib - add it to the server requirements or run pip install openupgradelib before the migration run

Try ERPeek on your own Odoo module - ask questions, scaffold tests, and explore your codebase in plain language.

Get started free