Skip to content

Writing Migration Scripts for Odoo Module Upgrades

DeployMonkey Team · March 23, 2026 12 min read

Why Migration Scripts?

When you change a module's data model (rename a field, change a field type, restructure data), Odoo needs instructions on how to transform existing data. Without migration scripts, you risk data loss, broken references, or upgrade failures. Migration scripts run automatically during -u module_name.

Migration Script Types

Odoo supports three types of migration scripts, executed in order:

  1. Pre-migration: runs before the ORM updates the database schema. Use for renaming columns, backing up data, and preparing for schema changes.
  2. Post-migration: runs after the ORM updates the schema. Use for data transformation, populating new fields, and cleaning up.
  3. End-migration: runs after all modules are updated. Use for cross-module data fixes.

File Structure

Migration scripts live in a migrations directory inside your module, organized by version number:

my_module/
  migrations/
    19.0.1.1.0/
      pre-migrate.py
      post-migrate.py
    19.0.1.2.0/
      pre-migrate.py
      post-migrate.py
      end-migrate.py
  __manifest__.py  # version: '19.0.1.2.0'

The directory name must match the new version number in __manifest__.py. Scripts only run when upgrading from an older version to this version.

Pre-Migration Script

Pre-migration runs before the ORM applies model changes. The database still has the old schema:

# migrations/19.0.1.1.0/pre-migrate.py
import logging

_logger = logging.getLogger(__name__)


def migrate(cr, version):
    """Rename old_field to new_field before ORM drops it."""
    _logger.info('Pre-migration: renaming old_field to new_field')

    # Check if old column exists
    cr.execute("""
        SELECT column_name FROM information_schema.columns
        WHERE table_name = 'my_model'
        AND column_name = 'old_field'
    """)
    if cr.fetchone():
        cr.execute("""
            ALTER TABLE my_model
            RENAME COLUMN old_field TO new_field
        """)
        _logger.info('Column renamed successfully')

The migrate(cr, version) function signature is required. cr is a raw database cursor (no ORM), and version is the module's current installed version.

Post-Migration Script

Post-migration runs after the ORM has updated the schema. You can use the ORM here via the env:

# migrations/19.0.1.1.0/post-migrate.py
import logging
from odoo import api, SUPERUSER_ID

_logger = logging.getLogger(__name__)


def migrate(cr, version):
    """Populate the new computed_status field."""
    env = api.Environment(cr, SUPERUSER_ID, {})

    _logger.info('Post-migration: computing status for existing records')

    records = env['my.model'].search([])
    for record in records:
        if record.old_state == 'active':
            record.status = 'published'
        elif record.old_state == 'inactive':
            record.status = 'draft'
        else:
            record.status = 'archived'

    _logger.info('Updated %d records', len(records))

Common Migration Patterns

Renaming a Field

# pre-migrate.py: rename column before ORM drops old + creates new
def migrate(cr, version):
    cr.execute("""
        ALTER TABLE sale_order
        RENAME COLUMN old_name TO new_name
    """)

Changing Field Type

# pre-migrate.py: backup data before type change
def migrate(cr, version):
    cr.execute("""
        ALTER TABLE my_table
        ADD COLUMN temp_backup VARCHAR
    """)
    cr.execute("""
        UPDATE my_table
        SET temp_backup = old_field::VARCHAR
    """)

# post-migrate.py: restore data in new format
def migrate(cr, version):
    env = api.Environment(cr, SUPERUSER_ID, {})
    cr.execute("SELECT id, temp_backup FROM my_table")
    for row_id, old_value in cr.fetchall():
        record = env['my.model'].browse(row_id)
        record.new_field = transform(old_value)
    cr.execute("ALTER TABLE my_table DROP COLUMN temp_backup")

Adding a Required Field

# post-migrate.py: set default for existing records
def migrate(cr, version):
    cr.execute("""
        UPDATE my_table
        SET new_required_field = 'default_value'
        WHERE new_required_field IS NULL
    """)

Moving Data Between Models

# post-migrate.py
def migrate(cr, version):
    env = api.Environment(cr, SUPERUSER_ID, {})
    cr.execute("""
        SELECT id, address_street, address_city
        FROM old_model
        WHERE address_street IS NOT NULL
    """)
    for old_id, street, city in cr.fetchall():
        env['new.address'].create({
            'old_model_id': old_id,
            'street': street,
            'city': city,
        })

Updating Selection Values

# pre-migrate.py
def migrate(cr, version):
    cr.execute("""
        UPDATE support_ticket
        SET state = 'in_progress'
        WHERE state = 'working'
    """)

Version Numbering

Odoo version format: odoo_version.module_major.module_minor.module_patch

# __manifest__.py
{
    'version': '19.0.1.2.0',  # Odoo 19, module v1.2.0
}

Bump the version whenever you need migration scripts. Odoo compares the installed version with the manifest version to determine which migration scripts to run.

Testing Migrations

# 1. Backup the database
pg_dump mydb > backup.sql

# 2. Run the upgrade
odoo -d mydb -u my_module --stop-after-init

# 3. Check logs for errors
grep 'ERROR\|migrate' /var/log/odoo/odoo.log

# 4. Verify data integrity
odoo shell -d mydb
>>> env['my.model'].search_count([])
>>> env['my.model'].search([('status', '=', False)])

Safety Best Practices

  • Always backup the database before running migrations
  • Use raw SQL in pre-migration (ORM is not available yet)
  • Check if columns/tables exist before altering them (idempotent scripts)
  • Log every significant operation for debugging
  • Test on a copy of production data, not just test data
  • Handle NULL values explicitly since existing records may have empty fields
  • Keep migrations fast: avoid loops over large datasets; prefer bulk SQL updates