GuideOdoo MigrationMarch 13, 2026

Module Migration Scripts in Odoo 19:
Upgrade Custom Modules Across Versions

INTRODUCTION

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.

01

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.

Directory layout
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:

ScriptWhen It RunsORM Available?Use Case
pre-migrate.pyBefore ORM applies model changesOld schema still activeRename columns, back up data, drop constraints
post-migrate.pyAfter ORM applies model changesNew schema active, new fields existPopulate new fields, migrate data, update records
end-migrate.pyAfter ALL modules finish upgradingFull new environmentCross-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.

Version Numbering Convention

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.

02

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.

Python — migrations/19.0.2.0.0/pre-migrate.py
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.

Python — migrations/19.0.2.0.0/post-migrate.py
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.")
Why Raw SQL Instead of ORM in Pre-Migrate?

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.

03

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.

Python — Using openupgradelib for field & model renames
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_fields so the ORM recognizes the column under its new name
  • Updates ir_property records that reference the old field name (for property-type fields)
  • Updates ir_model_data references when renaming XML IDs
  • Handles Many2many join tables by renaming the column in the relation table too
Renaming vs. Replacing: Know the Difference

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.

04

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

ChangeOdoo 18Odoo 19Migration Action
Access groupscategory_id on groupsprivilege_id systemUpdate group references in security XML
QWeb templatest-esc for escapingt-out replaces t-escSearch-replace in XML templates
UoM categoriesMandatory uom_category_idCategory field removedDrop references, update constraints
Stock procurementprocurement.group linksstock.reference modelRewrite 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:

Python — Updating base XML ID references in post-migrate
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 — the attributes parameter is now keyword-only
  • name_search() is fully replaced by _name_search() for internal calls
  • _register_hook() now receives an update boolean parameter
  • default_get() must handle the new active_model context 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_data for your module's records where the referenced record no longer exists
  • Delete the orphaned ir_model_data entries to prevent "External ID not found" warnings
  • Check for ir_ui_menu entries pointing to deleted actions and remove them
  • Verify ir_cron records still reference valid model methods after the upgrade
05

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_dump of 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.
Shell — Migration test script with timing and rollback
#!/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_line had 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 CONSTRAINT on 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_dump of 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.
Batch Large Data Migrations

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.

06

4 Migration Script Mistakes That Corrupt Your Odoo Production Database

1

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.

Our Fix

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.

2

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.

Our Fix

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.

3

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.

Our Fix

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.

4

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.

Our Fix

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'.

BUSINESS ROI

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.

ZeroData Loss During Upgrades

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.

4xFaster Version Upgrades

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.

90%Fewer Post-Upgrade Bugs

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.

SEO NOTES

Optimization Metadata

Meta Desc

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.

H2 Keywords

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"

Stop Gambling with Production Data on Every Upgrade

Every Odoo upgrade without migration scripts is a coin flip. Maybe the ORM silently handles the schema changes. Maybe it drops a column with 3 years of commission data. Maybe a renamed XML ID in base breaks your entire approval workflow. You won't know until production is down and users are calling.

If you're planning an Odoo 18 to 19 migration — or any version upgrade — with custom modules, we can help. We audit your custom modules for migration risks, write and test migration scripts against your production data volumes, and execute the upgrade with a tested rollback plan. The cost of doing it right is a weekend maintenance window. The cost of doing it wrong is weeks of emergency data recovery.

Book a Free Migration Assessment