All posts
Developer10 min read

Writing Odoo Data Migration Scripts with migrate.py

Write reliable data migration scripts for Odoo module upgrades - use the migrations/ folder with pre and post hooks, raw SQL for structural changes, ORM for data fixup, and openupgradelib helpers to rename fields, move data, and recompute stored fields.

Writing Odoo Data Migration Scripts with migrate.py#

When Do You Need Migration Scripts?#

A migration script is required when upgrading a module that involves:

  • Renaming a field or model
  • Splitting one field into two
  • Changing a field's type (e.g., Char → Many2one)
  • Migrating data from one model to another
  • Recomputing stored fields that changed their compute logic
  • Deleting obsolete columns after data has been moved

Folder Structure#

my_module/
  __manifest__.py        ← version must increment (e.g., '1.2.0' → '1.3.0')
  migrations/
    1.3.0/
      pre-migration.py   ← runs before ORM applies changes
      post-migration.py  ← runs after ORM applies changes

Odoo compares the version in __manifest__.py against the installed version in ir.module.module. If the folder name matches the new version, the scripts run during -u my_module.

Pre-Migration: Structural SQL Changes#

Pre-migration runs before Odoo applies ORM schema changes. Do raw SQL here:

python
# migrations/1.3.0/pre-migration.py

def migrate(cr, version):
    if not version:
        return  # fresh install

    # Rename a column before ORM tries to create it fresh
    cr.execute("""
        ALTER TABLE sale_order_line
        ADD COLUMN IF NOT EXISTS unit_cost_new NUMERIC;
    """)
    cr.execute("""
        UPDATE sale_order_line
        SET unit_cost_new = unit_cost_old
        WHERE unit_cost_old IS NOT NULL;
    """)

Important: Do NOT import openupgrade in the function signature for standard Odoo scripts. Use the simple migrate(cr, version) signature (no decorator required in native Odoo migrations).

Post-Migration: ORM Data Fixup#

Post-migration runs after ORM applies schema changes. The environment is available:

python
# migrations/1.3.0/post-migration.py

def migrate(cr, version):
    if not version:
        return

    from odoo import api, SUPERUSER_ID

    env = api.Environment(cr, SUPERUSER_ID, {})

    # Example: populate a new Many2one from an old Char field
    products = env['product.template'].search([('legacy_category', '!=', False)])
    for product in products:
        category = env['product.category'].search(
            [('name', '=', product.legacy_category)], limit=1
        )
        if not category:
            category = env['product.category'].create(
                {'name': product.legacy_category}
            )
        product.categ_id = category

    # Recompute a stored field whose logic changed
    orders = env['sale.order'].search([])
    orders._compute_amount_total()

Splitting a Field Into Two#

python
# pre-migration.py: copy data before schema changes
def migrate(cr, version):
    if not version:
        return
    # Split 'full_address' (Char) into 'street' and 'city'
    cr.execute("""
        ALTER TABLE res_partner ADD COLUMN IF NOT EXISTS city_new VARCHAR;
        ALTER TABLE res_partner ADD COLUMN IF NOT EXISTS street_new VARCHAR;
    """)
    cr.execute("""
        UPDATE res_partner
        SET
            street_new = split_part(full_address, ',', 1),
            city_new   = trim(split_part(full_address, ',', 2))
        WHERE full_address IS NOT NULL;
    """)

# post-migration.py: copy from temp columns to real fields
def migrate(cr, version):
    if not version:
        return
    cr.execute("""
        UPDATE res_partner
        SET
            street = street_new,
            city   = city_new
        WHERE street_new IS NOT NULL;

        ALTER TABLE res_partner
            DROP COLUMN IF EXISTS street_new,
            DROP COLUMN IF EXISTS city_new;
    """)

Changing a Char to Many2one#

python
# post-migration.py
def migrate(cr, version):
    if not version:
        return

    from odoo import api, SUPERUSER_ID
    env = api.Environment(cr, SUPERUSER_ID, {})

    # Read from a temp column that held the old Char value
    cr.execute("SELECT id, old_supplier_name FROM purchase_order WHERE old_supplier_name IS NOT NULL")
    rows = cr.fetchall()

    for order_id, supplier_name in rows:
        partner = env['res.partner'].search([('name', 'ilike', supplier_name)], limit=1)
        if partner:
            env['purchase.order'].browse(order_id).partner_id = partner

Deleting Obsolete Models#

When a model is removed from your module, clean up the database:

python
# pre-migration.py
def migrate(cr, version):
    if not version:
        return
    # Remove model registry entries before ORM errors on missing model
    cr.execute("DELETE FROM ir_model WHERE model = 'my.obsolete.model'")
    cr.execute("DROP TABLE IF EXISTS my_obsolete_model")

Testing the Migration#

bash
# 1. Clone the DB
createdb -T production_db migration_test_db

# 2. Upgrade the module
odoo -d migration_test_db -u my_module --stop-after-init

# 3. Check for errors in the log
grep -i "error|traceback" odoo.log | head -50

# 4. Verify data
psql migration_test_db -c "SELECT COUNT(*) FROM my_model WHERE new_field IS NULL AND old_field IS NOT NULL;"

Common Mistakes#

  • Not incrementing version in __manifest__.py - Odoo won't run migration scripts if the version hasn't changed
  • Dropping columns in pre-migration - ORM may still reference the column; drop only in post-migration after ORM has run
  • Using ORM in pre-migration - the ORM is not safe before schema changes are applied; use raw SQL only
  • Not guarding against fresh installs - always check if not version: return
  • Forgetting to create the migrations/ folder in __manifest__.py - Odoo finds migration scripts from the module path, not from a manifest entry, but the folder must be inside the module directory

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

Get started free