Your Custom Module Works Perfectly on Odoo 18. Then You Upgrade to 19 and Everything Breaks.
Every Odoo major version ships breaking changes. Fields get renamed. Models are restructured. API methods that worked for three versions are deprecated overnight. And your custom module — the one that handles your entire commission workflow or your bespoke manufacturing integration — stops loading entirely because the ORM can't reconcile the old schema with the new expectations.
The standard advice is "just fix the errors." But anyone who has migrated a 50-model custom module across two Odoo versions knows that fixing errors one by one is a death march. You fix one KeyError, another psycopg2.ProgrammingError appears. You rename a column, and three computed fields that depended on the old name start returning None. You update an XML ID, and a scheduled action stops matching its record.
Migration scripts are Odoo's built-in mechanism for handling this systematically. They run automatically during module upgrade, they execute in a predictable order, and they give you direct SQL and ORM access to reshape your data before, during, and after the upgrade. This guide covers everything you need to write production-grade migration scripts for Odoo 19 — from folder structure to field renaming, XML ID updates, OpenUpgrade helpers, testing strategies, and rollback plans.
How Odoo 19 Discovers and Executes Migration Scripts: The Version Folder Convention
Odoo's migration framework is convention-based. It looks for scripts inside a migrations directory (or the legacy migrations alias) within your module, organized by version number. The version must match the version in your __manifest__.py — if the manifest says 19.0.2.0.0 and the database has 19.0.1.0.0, Odoo will run every migration folder between those two versions in order.
my_custom_module/
├── __manifest__.py # version: "19.0.2.0.0"
├── models/
├── views/
├── data/
├── migrations/
│ ├── 19.0.1.1.0/
│ │ ├── pre-migrate.py # Runs BEFORE ORM updates the schema
│ │ ├── post-migrate.py # Runs AFTER ORM updates the schema
│ │ └── end-migrate.py # Runs AFTER all modules are upgraded
│ └── 19.0.2.0.0/
│ ├── pre-migrate.py
│ └── post-migrate.py
└── tests/The execution order is critical:
| Script | When It Runs | ORM Available? | Use Case |
|---|---|---|---|
| pre-migrate.py | Before ORM applies model changes | Old schema still active | Rename columns, back up data, drop constraints |
| post-migrate.py | After ORM applies model changes | New schema active, new fields exist | Populate new fields, migrate data, update records |
| end-migrate.py | After ALL modules finish upgrading | Full new environment | Cross-module data fixes, final cleanups |
Each script must define a single migrate(cr, version) function. The cr parameter is a raw database cursor — you can run SQL directly. The version parameter is the version string being migrated to, or None on fresh install (in which case the script should return early).
Odoo processes migration folders sequentially in version order. If your database is at 19.0.1.0.0 and the manifest says 19.0.3.0.0, it will run 19.0.1.1.0/pre-migrate.py, then 19.0.2.0.0/pre-migrate.py, then 19.0.3.0.0/pre-migrate.py, then the ORM applies all schema changes, then it runs all the post-migrate scripts in the same version order. This means each script can safely assume all previous scripts have already run.
Use the format ODOO_VERSION.MODULE_MAJOR.MODULE_MINOR.PATCH — e.g., 19.0.2.0.0. The first two segments (19.0) match the Odoo series. The remaining three are your module's semantic version. Every time you need a migration script, bump the version in __manifest__.py and create a matching folder. If the version doesn't change, Odoo won't look for scripts.
Writing Pre-Migrate, Post-Migrate, and End-Migrate Scripts for Odoo 19 Modules
Let's walk through a real scenario. You have a custom module sale_commission that tracked commissions with a Float field called commission_rate on sale.order.line. In the new version, you're replacing it with a Many2one to a new commission.tier model. This requires a coordinated migration across all three script types.
Pre-Migrate: Preserve Data Before the ORM Destroys It
The pre-migrate script runs while the old schema is still intact. The ORM hasn't touched anything yet. This is your chance to back up data that the ORM would otherwise silently drop when it sees that commission_rate changed from a Float to something else.
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
"""Back up commission_rate before ORM replaces the column."""
if not version:
return # Fresh install, nothing to migrate
_logger.info("sale_commission: pre-migrate to %s", version)
# Check if the old column exists before touching it
cr.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'sale_order_line'
AND column_name = 'commission_rate'
""")
if not cr.fetchone():
_logger.info("commission_rate column not found, skipping.")
return
# Rename the column so the ORM doesn't drop it
# The ORM will create the new Many2one field separately
cr.execute("""
ALTER TABLE sale_order_line
RENAME COLUMN commission_rate
TO x_legacy_commission_rate
""")
_logger.info(
"Renamed commission_rate -> x_legacy_commission_rate"
)Post-Migrate: Populate New Fields from Legacy Data
After the ORM has created the new commission_tier_id Many2one field and the commission.tier model table, the post-migrate script maps old float values to the new tier records.
import logging
from odoo import api, SUPERUSER_ID
_logger = logging.getLogger(__name__)
# Mapping: old float ranges to new tier XML IDs
TIER_MAP = [
(0.0, 0.05, "sale_commission.tier_standard"),
(0.05, 0.10, "sale_commission.tier_silver"),
(0.10, 0.15, "sale_commission.tier_gold"),
(0.15, 1.0, "sale_commission.tier_platinum"),
]
def migrate(cr, version):
"""Map legacy commission_rate floats to new tier records."""
if not version:
return
_logger.info("sale_commission: post-migrate to %s", version)
# Check if backup column exists
cr.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'sale_order_line'
AND column_name = 'x_legacy_commission_rate'
""")
if not cr.fetchone():
_logger.warning("No legacy column found. Skipping.")
return
env = api.Environment(cr, SUPERUSER_ID, {})
for low, high, xml_id in TIER_MAP:
tier = env.ref(xml_id, raise_if_not_found=False)
if not tier:
_logger.warning("Tier %s not found, skipping.", xml_id)
continue
cr.execute("""
UPDATE sale_order_line
SET commission_tier_id = %s
WHERE x_legacy_commission_rate >= %s
AND x_legacy_commission_rate < %s
AND commission_tier_id IS NULL
""", (tier.id, low, high))
_logger.info(
"Mapped %d rows to tier %s",
cr.rowcount, xml_id
)
# Clean up the backup column
cr.execute("""
ALTER TABLE sale_order_line
DROP COLUMN IF EXISTS x_legacy_commission_rate
""")
_logger.info("Dropped x_legacy_commission_rate column.") In pre-migrate scripts, the ORM's Python model definitions reflect the new code (you've already updated your Python files), but the database still has the old schema. Using env['sale.order.line'].search([]) in a pre-migrate script will crash because the ORM tries to SELECT columns that don't exist yet. Always use raw SQL via cr.execute() in pre-migrate scripts. The ORM is only safe in post-migrate and end-migrate scripts.
Renaming Fields, Models, and XML IDs Without Losing Data in Odoo 19
Renaming is the most common migration task and the easiest to get wrong. If you simply rename a field in your Python model and bump the version, Odoo will drop the old column and create a new empty one. All your data is gone. Migration scripts prevent this by telling the database to rename rather than drop-and-create.
Renaming Fields with OpenUpgrade Helpers
The openupgradelib library provides battle-tested helpers that handle edge cases you'd otherwise miss — like renaming the column, updating ir.model.fields, and fixing related ir.property records. Install it with pip install openupgradelib.
from openupgradelib import openupgrade
# ── Field rename ──────────────────────────────────
# In pre-migrate: rename before ORM drops the old column
@openupgrade.migrate()
def migrate(env, version):
openupgrade.rename_fields(
env,
[
# (model, old_table, old_field, new_field)
(
"project.task",
"project_task",
"planned_hours",
"estimated_hours",
),
(
"project.task",
"project_task",
"reviewer_id",
"approver_id",
),
],
)
# ── Model rename ──────────────────────────────
openupgrade.rename_models(
env.cr,
[
# (old_model, new_model)
("sale.commission.rule", "commission.tier"),
],
)
# ── Table rename ──────────────────────────────
openupgrade.rename_tables(
env.cr,
[
("sale_commission_rule", "commission_tier"),
],
)
# ── XML ID rename ─────────────────────────────
openupgrade.rename_xmlids(
env.cr,
[
# (old_xml_id, new_xml_id)
(
"sale_commission.rule_standard",
"sale_commission.tier_standard",
),
(
"sale_commission.rule_premium",
"sale_commission.tier_gold",
),
],
) What openupgrade.rename_fields does under the hood:
- Renames the PostgreSQL column via
ALTER TABLE ... RENAME COLUMN - Updates
ir_model_fieldsso the ORM recognizes the column under its new name - Updates
ir_propertyrecords that reference the old field name (for property-type fields) - Updates
ir_model_datareferences when renaming XML IDs - Handles Many2many join tables by renaming the column in the relation table too
Use rename_fields when the data type stays the same — e.g., renaming planned_hours to estimated_hours (both Float). When the data type changes (Float to Many2one, Char to Selection), renaming won't work. You need the backup-and-populate pattern from Step 02: back up the old column in pre-migrate, let the ORM create the new field, then populate it in post-migrate.
Migrating Data, XML IDs, and Handling API Changes Between Odoo Versions
Beyond field renames, version upgrades bring XML ID changes in base modules, deprecated API methods, and restructured data formats. Odoo 19 introduced several breaking changes that custom modules must account for.
Key Odoo 19 API Changes to Handle in Migration
| Change | Odoo 18 | Odoo 19 | Migration Action |
|---|---|---|---|
| Access groups | category_id on groups | privilege_id system | Update group references in security XML |
| QWeb templates | t-esc for escaping | t-out replaces t-esc | Search-replace in XML templates |
| UoM categories | Mandatory uom_category_id | Category field removed | Drop references, update constraints |
| Stock procurement | procurement.group links | stock.reference model | Rewrite procurement logic |
Updating XML IDs That Changed in Base Modules
When Odoo renames or removes an XML ID in a base module, any custom module that references it via env.ref() or in XML data files will break. The fix is a post-migrate script that updates ir_model_data references:
import logging
_logger = logging.getLogger(__name__)
# XML IDs renamed in Odoo 19 base modules
XMLID_RENAMES = [
# (old_module, old_name, new_module, new_name)
("base", "group_erp_manager",
"base", "group_system"),
("stock", "group_stock_multi_locations",
"stock", "group_stock_storage_categories"),
]
def migrate(cr, version):
"""Fix references to XML IDs renamed in Odoo 19."""
if not version:
return
for old_mod, old_name, new_mod, new_name in XMLID_RENAMES:
cr.execute("""
UPDATE ir_model_data
SET module = %s, name = %s
WHERE module = %s AND name = %s
""", (new_mod, new_name, old_mod, old_name))
if cr.rowcount:
_logger.info(
"Renamed XML ID %s.%s -> %s.%s",
old_mod, old_name, new_mod, new_name
)
# Update ir.config_parameter references
cr.execute("""
UPDATE ir_config_parameter
SET value = REPLACE(value, 'group_erp_manager',
'group_system')
WHERE value LIKE '%%group_erp_manager%%'
""")
_logger.info(
"Updated %d config parameters.", cr.rowcount
)Handling Deprecated ORM Methods
Odoo 19 deprecated several ORM methods. Your migration scripts don't need to handle these (they're code changes, not data changes), but your module code must be updated before the migration runs. Common changes:
fields_get()signature changed — theattributesparameter is now keyword-onlyname_search()is fully replaced by_name_search()for internal calls_register_hook()now receives anupdateboolean parameterdefault_get()must handle the newactive_modelcontext key
Migrating Stored Computed Fields
A common pitfall occurs when a stored computed field's depends list references a field that was renamed in the new version. The ORM will try to compute the field using the new dependency chain, but the underlying data still references the old column. The result: every record's computed value resets to zero or blank after upgrade.
The fix is to pre-compute and store the value in a temporary column during pre-migrate, then copy it back after the ORM recreates the computed field in post-migrate. This preserves historical computed values that would otherwise be lost to a full recomputation.
Cleaning Up Orphaned ir.model.data Records
When you remove a record from your XML data files between versions, Odoo marks the ir.model.data entry as noupdate=True but doesn't always delete the underlying record. Over multiple upgrades, these orphaned records accumulate — phantom menu items, ghost security groups, and stale scheduled actions. A good end-migrate script cleans these up:
- Query
ir_model_datafor your module's records where the referenced record no longer exists - Delete the orphaned
ir_model_dataentries to prevent "External ID not found" warnings - Check for
ir_ui_menuentries pointing to deleted actions and remove them - Verify
ir_cronrecords still reference valid model methods after the upgrade
Testing Migration Scripts and Building a Rollback Strategy for Odoo 19 Upgrades
Migration scripts that work on your development database with 50 records will fail on production with 500,000 records. Testing against realistic data volumes is not optional — it's the difference between a 10-minute upgrade and a 6-hour emergency rollback.
The Migration Test Workflow
We follow a four-stage testing process for every migration:
- Stage 1: Dev database — Write the scripts, run
-u my_module, verify the schema changes. This catches syntax errors and logic bugs. - Stage 2: Anonymized production copy — Restore a
pg_dumpof production (with sensitive data anonymized), run the migration. This catches data-dependent bugs and performance issues. - Stage 3: Timing benchmark — Measure how long the migration takes on realistic data. If it exceeds your maintenance window, optimize the SQL (add indexes, batch updates).
- Stage 4: Dry run on staging — Full end-to-end test on a staging server that mirrors production infrastructure. Users verify key workflows after the upgrade.
#!/bin/bash
set -euo pipefail
DB_NAME="odoo_migration_test"
DB_BACKUP="odoo_prod_anonymized.sql.gz"
MODULE="sale_commission"
ODOO_BIN="/opt/odoo/odoo-bin"
ODOO_CONF="/etc/odoo/odoo.conf"
echo "=== Step 1: Restore production snapshot ==="
dropdb --if-exists "$DB_NAME"
createdb "$DB_NAME"
gunzip -c "$DB_BACKUP" | psql -q "$DB_NAME"
echo "=== Step 2: Record pre-migration state ==="
pg_dump "$DB_NAME" | gzip > "/tmp/${DB_NAME}_pre.sql.gz"
echo "=== Step 3: Run migration with timing ==="
START=$(date +%s)
$ODOO_BIN -c "$ODOO_CONF" \
-d "$DB_NAME" \
-u "$MODULE" \
--stop-after-init \
--log-level=info 2>&1 | tee /tmp/migration.log
END=$(date +%s)
DURATION=$((END - START))
echo "Migration completed in ${DURATION}s"
echo "=== Step 4: Verify migration ==="
# Check that no ERROR lines appear in the log
if grep -q "ERROR" /tmp/migration.log; then
echo "ERRORS detected in migration log!"
echo "Restoring pre-migration snapshot..."
dropdb "$DB_NAME"
createdb "$DB_NAME"
gunzip -c "/tmp/${DB_NAME}_pre.sql.gz" \
| psql -q "$DB_NAME"
echo "Rollback complete."
exit 1
fi
echo "=== Step 5: Run post-migration sanity checks ==="
psql "$DB_NAME" -c "
SELECT COUNT(*) AS total_lines,
COUNT(commission_tier_id) AS migrated,
COUNT(*) - COUNT(commission_tier_id) AS unmigrated
FROM sale_order_line
WHERE x_legacy_commission_rate IS NOT NULL
OR commission_tier_id IS NOT NULL;
"
echo "Migration test passed in ${DURATION}s."Automated Migration Verification Queries
After the migration completes, run these verification queries to confirm data integrity. Build them into your test script so they run automatically:
- Row count comparison: Compare row counts of key tables before and after migration. If
sale_order_linehad 250,000 rows before and 249,998 after, you have a problem. - NULL field audit: Check that new fields populated by migration scripts have no unexpected NULLs —
SELECT COUNT(*) FROM sale_order_line WHERE commission_tier_id IS NULL AND create_date < '2026-01-01'. - XML ID integrity: Verify that all XML IDs in your module resolve to existing records —
SELECT * FROM ir_model_data WHERE module = 'my_module' AND res_id NOT IN (SELECT id FROM ...). - Constraint validation: Run
ALTER TABLE ... VALIDATE CONSTRAINTon any constraints you dropped and recreated during migration.
Production Rollback Strategy
Migration scripts modify database schema. You cannot "undo" an ALTER TABLE by reverting code. Your rollback plan must include the database:
- Before migration: Take a full
pg_dumpof the production database. Store it on a separate volume or S3 bucket — not on the same server. - Rollback trigger: Define clear criteria before starting — "if migration takes more than 30 minutes" or "if health check fails within 5 minutes of restart."
- Rollback procedure: Stop Odoo, restore the database dump, revert the code to the previous release tag, restart Odoo. The entire procedure should be scripted and tested during Stage 4.
- Point of no return: Once users start creating new records on the migrated schema, a database rollback means losing that data. Communicate the maintenance window clearly and prevent user access during migration.
If your migration script updates millions of rows, don't do it in a single UPDATE statement. PostgreSQL will lock the entire table and your migration will hold up every other operation. Use batched updates: UPDATE ... WHERE id IN (SELECT id FROM ... LIMIT 10000) in a loop with cr.commit() between batches. This keeps lock duration short and lets you monitor progress. For tables with 10M+ rows, consider adding a temporary index on the filter column before running the batch update.
4 Migration Script Mistakes That Corrupt Your Odoo Production Database
Using the ORM in Pre-Migrate Scripts
The most common migration bug. In pre-migrate scripts, the Python model definitions reflect your new code, but the database still has the old schema. Calling env['my.model'].search([]) will crash because the ORM tries to SELECT columns that don't exist yet — or worse, it silently returns wrong data because a renamed column has a different meaning in the new code.
Use raw SQL exclusively in pre-migrate scripts. Reserve ORM usage for post-migrate and end-migrate scripts where the schema matches the Python model definitions.
Forgetting the if not version: return Guard
Migration scripts also run on fresh installations when the module is installed for the first time. Without the version guard, your script will try to rename columns that don't exist, drop constraints that were never created, or populate fields from legacy data that isn't there. The result is a ProgrammingError that blocks module installation entirely.
Every migration function starts with if not version: return. No exceptions. We enforce this with a pre-commit hook that greps migration files for the guard pattern.
Not Testing with Production Data Volumes
A migration script that runs in 2 seconds on your dev database with 100 sale orders can take 45 minutes on production with 200,000 sale orders. An unindexed UPDATE ... WHERE on a large table triggers a full sequential scan. A LIKE pattern match on a text column with no index turns a 3-second query into a 20-minute lock fest. Your maintenance window expires, users are locked out, and you're debugging SQL performance under pressure.
Always test migrations on an anonymized copy of the production database. Use EXPLAIN ANALYZE on every query in your migration scripts. If any query takes more than 10 seconds, add a temporary index or restructure as a batched update.
Hardcoding Record IDs Instead of Using XML IDs
Migration scripts that reference records by integer ID — UPDATE ... SET group_id = 42 — work on the developer's database and break everywhere else. Database IDs are auto-incremented and differ between environments. A record that has id = 42 on your dev server might have id = 137 on production because a different set of demo data was loaded.
Always resolve records via XML ID using env.ref('module.xml_id') in post-migrate scripts. In pre-migrate scripts where the ORM isn't safe, query ir_model_data directly: SELECT res_id FROM ir_model_data WHERE module = 'base' AND name = 'group_system'.
What Proper Migration Scripts Save Your Odoo Operation
Migration scripts are an investment that pays off every time you upgrade. Without them, every version bump is a gamble with production data. With them, upgrades become predictable, testable, and reversible.
Migration scripts explicitly map every renamed field and restructured model. No silent data drops, no empty columns after upgrade, no "where did our commission history go?" tickets.
Teams with migration scripts upgrade in a planned maintenance window. Teams without them spend weeks in emergency data recovery and manual SQL fixes after a botched upgrade.
Tested migration scripts catch schema mismatches before production. The bugs that slip through are business logic edge cases, not structural failures that crash the entire module.
The hidden cost of skipping migration scripts is technical debt that compounds with every version. Each upgrade without proper migration leaves behind orphaned columns, stale XML ID references, and data inconsistencies. By the time you decide to "do it properly," you're not migrating one version — you're untangling three versions of accumulated schema drift. The cost of writing migration scripts today is a fraction of the cost of archaeological data recovery later.
Optimization Metadata
Write production-grade Odoo 19 module migration scripts. Covers pre/post/end-migrate patterns, field and model renaming, OpenUpgrade helpers, XML ID fixes, testing, and rollback strategies.
1. "How Odoo 19 Discovers and Executes Migration Scripts: The Version Folder Convention"
2. "Writing Pre-Migrate, Post-Migrate, and End-Migrate Scripts for Odoo 19 Modules"
3. "Renaming Fields, Models, and XML IDs Without Losing Data in Odoo 19"
4. "4 Migration Script Mistakes That Corrupt Your Odoo Production Database"