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 changesOdoo 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:
# 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:
# 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#
# 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#
# 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 = partnerDeleting Obsolete Models#
When a model is removed from your module, clean up the database:
# 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#
# 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
versionin__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

