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:
- Pre-migration: runs before the ORM updates the database schema. Use for renaming columns, backing up data, and preparing for schema changes.
- Post-migration: runs after the ORM updates the schema. Use for data transformation, populating new fields, and cleaning up.
- 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