This article is currently available in English only. Translation coming soon.
By the end of this recipe, your Odoo custom module will run idempotent pre-migration and post-migration scripts at upgrade time — backfilling new columns, transforming legacy data, and skipping cleanly when re-run. Skill required: Python developer with Odoo + PostgreSQL fluency. Time required: 90 minutes per migration script. ECOSIRE has written hundreds of migration scripts for module upgrades and Odoo major-version migrations, and the recipe below is the playbook.
The mistake most teams make: writing a migration script that runs on every module update, even when the data has already been transformed. Re-running the script then double-applies the transformation (e.g., doubles a default value, or appends an already-appended suffix). The recipe below uses version checks to make scripts idempotent.
What you will need
- Odoo version: 17, 18, or 19. The migration framework is identical.
- Custom module under upgrade.
- Test database: never test migrations on production.
- Time: 90 minutes per migration.
Step-by-step
1. Migration directory structure
custom_module/
__manifest__.py
migrations/
19.0.1.1.0/
pre-migrate.py
post-migrate.py
19.0.1.2.0/
pre-migrate.py
post-migrate.py
Odoo runs the appropriate folder based on the module's version in __manifest__.py and the version recorded in ir_module_module.
Verification: bumping __manifest__.py from 19.0.1.0.0 to 19.0.1.1.0 and running -u custom_module triggers the 19.0.1.1.0/ scripts.
2. Write a pre-migrate script
Pre-migrate runs BEFORE the module's data files are loaded. Use it for schema-altering or data-transforming operations on existing rows:
# migrations/19.0.1.1.0/pre-migrate.py
def migrate(cr, version):
"""Convert customer_segment from text to selection.
Was a freeform Char in 19.0.1.0.0; becomes a Selection in 19.0.1.1.0.
"""
if not version:
# Module is being installed fresh, not upgraded; skip
return
# Map known string values to canonical selection keys
cr.execute("""
UPDATE res_partner
SET customer_segment = CASE LOWER(customer_segment)
WHEN 'strategic' THEN 'strategic'
WHEN 'enterprise' THEN 'enterprise'
WHEN 'mid market' THEN 'mid_market'
WHEN 'mid-market' THEN 'mid_market'
WHEN 'smb' THEN 'smb'
WHEN 'small business' THEN 'smb'
WHEN 'consumer' THEN 'consumer'
ELSE 'smb' -- safe default
END
WHERE customer_segment IS NOT NULL
AND customer_segment != ''
""")
# Set NULL/empty values to default
cr.execute("UPDATE res_partner SET customer_segment = 'smb' WHERE customer_segment IS NULL OR customer_segment = ''")
The version parameter is the version being upgraded FROM, not TO. Empty/None means fresh install.
Verification: a fresh install skips the migration; an upgrade from 19.0.1.0.0 runs it.
3. Write a post-migrate script
Post-migrate runs AFTER the module's data files are loaded and views are updated. Use it for operations that need the new schema in place:
# migrations/19.0.1.1.0/post-migrate.py
from odoo import api, SUPERUSER_ID
def migrate(cr, version):
"""After the new strategic_account_manager_id field is loaded, assign
a default value based on customer_segment.
"""
if not version:
return
env = api.Environment(cr, SUPERUSER_ID, {})
default_manager = env['res.users'].search([('login', '=', 'admin')], limit=1)
if not default_manager:
return
env['res.partner'].search([
('customer_segment', '=', 'strategic'),
('strategic_account_manager_id', '=', False),
]).write({'strategic_account_manager_id': default_manager.id})
Verification: post-migration, every Strategic customer has a strategic_account_manager_id assigned.
4. Make scripts idempotent
If someone runs the upgrade twice, the script should detect "already applied" and skip:
def migrate(cr, version):
cr.execute("SELECT 1 FROM information_schema.columns WHERE table_name='res_partner' AND column_name='strategic_account_manager_id'")
if not cr.fetchone():
# Field doesn't exist yet — odd, but skip safely
return
cr.execute("""
SELECT COUNT(*) FROM res_partner
WHERE customer_segment = 'strategic' AND strategic_account_manager_id IS NULL
""")
pending = cr.fetchone()[0]
if pending == 0:
# Already migrated
return
# Run the migration
...
Verification: running -u custom_module twice does not double-apply.
5. Use openupgradelib for major-version migrations
For migrating between major versions (e.g., 17 to 18 to 19), use the openupgradelib library which provides high-level helpers:
from openupgradelib import openupgrade
def migrate(cr, version):
openupgrade.rename_columns(cr, {'res_partner': [('old_field', 'new_field')]})
openupgrade.copy_columns(cr, {'res_partner': [('email', None, 'email_normalized')]})
openupgrade.logged_query(cr, """
UPDATE res_partner SET email_normalized = LOWER(email) WHERE email IS NOT NULL
""")
Verification: pip install openupgradelib and the helpers work as documented.
6. Handle large datasets in batches
Single-statement updates over millions of rows can timeout or balloon WAL. Batch:
def migrate(cr, version):
BATCH_SIZE = 10000
while True:
cr.execute("""
WITH to_migrate AS (
SELECT id FROM res_partner WHERE migrated IS NULL LIMIT %s
)
UPDATE res_partner SET migrated = TRUE
WHERE id IN (SELECT id FROM to_migrate)
RETURNING id
""", (BATCH_SIZE,))
if cr.rowcount == 0:
break
cr.commit() # release locks; allow other transactions to make progress
Verification: a 10M-row table migrates without filling /var/lib/postgresql with WAL.
7. Add safety checks and rollback prep
Before destructive migrations, validate state and back up:
def migrate(cr, version):
cr.execute("SELECT COUNT(*) FROM res_partner")
count_before = cr.fetchone()[0]
if count_before == 0:
raise Exception("Migration aborted: zero partners. Database might be wrong.")
# Capture pre-state for rollback (rarely used but invaluable when needed)
cr.execute("""
CREATE TABLE IF NOT EXISTS res_partner_pre_19_0_1_1_0 AS
SELECT id, customer_segment FROM res_partner WHERE customer_segment IS NOT NULL
""")
# Now do the actual migration
...
# Verify count didn't change
cr.execute("SELECT COUNT(*) FROM res_partner")
if cr.fetchone()[0] != count_before:
raise Exception("Migration aborted: row count changed unexpectedly.")
Verification: a backup table res_partner_pre_19_0_1_1_0 exists post-migration.
8. Test in a copy of production
sudo -u postgres pg_dump production -Fc > /tmp/production.dump
sudo -u postgres dropdb migration_test_db || true
sudo -u postgres createdb -O odoo migration_test_db
sudo -u postgres pg_restore -d migration_test_db /tmp/production.dump
sudo -u odoo /opt/odoo/venv/bin/python /opt/odoo/odoo/odoo-bin \
-c /etc/odoo/odoo.conf -d migration_test_db -u custom_module --stop-after-init
If migration_test_db looks correct, run on production with confidence. Verification: the test DB shows the expected post-migration state.
Common mistakes
- Forgetting to check
if not version:. Migrations run on fresh installs, blowing up because they reference data that doesn't exist. - No idempotency. Re-running doubles the effect. Always test by running the migration twice.
- Running on production without testing on a copy first. Migrations are forward-only; mistakes are expensive.
- Skipping commits in long batches. Long uncommitted transactions hold locks and bloat WAL.
- No backup table for destructive changes. When the migration is wrong (it happens), you have no path back.
Going further
OpenUpgrade major-version: a community project that maintains migration scripts from Odoo N to N+1. Worth contributing to and using. They handle the schema deltas (removed fields, renamed columns, type changes) for every officially-supported module.
Pre/post hooks vs migrate scripts: pre_init_hook runs once at module install. Migrations run once per version bump. Don't mix them. Use migrations for upgrades; use init hooks for one-time install setup.
Migration runner: build a small wrapper script that captures pre/post counts, runs migration, and emits a diff report. Run on every customer database during a managed upgrade engagement; review the diffs before declaring success.
Per-customer dry-run: run --dry-run on a copy of each customer's database before running on production. Each customer's data shape is unique; what worked for client A may break for client B.
Data validation framework: write a separate validate.py per migration that runs after the upgrade and checks invariants (counts match, totals balance, no orphaned records). Auto-rollback if validation fails.
Schema change detection: use pg_dump --schema-only before and after the migration. Diff the output to confirm only intended schema changes happened.
Migration timing: long-running migrations should run during a maintenance window. Pair with Settings > Lock Date to prevent users posting transactions while migration is running.
Cross-module dependencies: when multiple modules need migration, run them in dependency order. The Odoo loader handles this automatically based on depends.
Rollback strategy: most migrations are forward-only. If you need rollback, have the pre-migration backup and a clear restore procedure documented.
Idempotent UPDATE statements: use WHERE clauses that select only the rows still needing transformation. Re-running the script is then a no-op for already-transformed rows.
Batch progress logging: log every 1000 rows so you can monitor progress on long migrations. Without it, a 10-minute migration looks frozen.
Test migration on disposable copy first: pg_dump production | pg_restore -d test_migration then run the upgrade. Verify outcomes. Only then run on production.
Post-migration smoke tests: run a battery of automated UI clicks (Selenium/Playwright) immediately after migration to catch regressions before users see them.
Communication plan: tell users the migration is happening, what to expect (downtime, change list), and how to report issues. A good migration is invisible; a bad one needs proactive communication.
For complex multi-version migrations including data quality fixes and business-rule transitions, ECOSIRE Odoo migration services handle the entire program. Pair this with how to back up and restore an Odoo database.
Frequently Asked Questions
Where does Odoo know my module version was 19.0.1.0.0?
In the ir_module_module table, the latest_version column stores the version at last successful install/update. Bumping the manifest and re-running -u triggers migration scripts for the version range.
Can a migration script call Odoo ORM (env)?
Yes — wrap with api.Environment(cr, SUPERUSER_ID, {}). Useful for complex operations. But for raw data updates, plain SQL is faster and safer.
What happens if a migration script raises?
The transaction rolls back. The module's latest_version stays at the old value. Re-running -u re-runs the script.
How do I migrate across major versions?
Step by step: 17 to 18, then 18 to 19. Cross-major is the riskiest because of schema changes; test very carefully.
For end-to-end Odoo upgrade engagements including OpenUpgrade integration and 30-day parallel run, ECOSIRE Odoo migration services ship full programs. Or read how to package an Odoo module for distribution.
تحریر
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
ECOSIRE
Odoo ERP کے ساتھ اپنے کاروبار کو تبدیل کریں
آپ کے کاموں کو ہموار کرنے کے لیے ماہر Odoo کا نفاذ، حسب ضرورت، اور معاونت۔
متعلقہ مضامین
How to Add a Custom Button to an Odoo Form View (2026)
Add custom action buttons to Odoo 19 form views: Python action method, view inheritance, conditional visibility, confirmation dialogs. Production-tested.
How to Add a Custom Field in Odoo Without Studio (2026)
Add custom fields via custom module in Odoo 19: model inheritance, view extension, computed fields, store/non-store decisions. Code-first, version-controlled.
How to Add a Custom Report in Odoo Using External Layout
Build a branded PDF report in Odoo 19 using web.external_layout: QWeb template, paperformat, action binding. With print logo + footer overrides.