Enable Dark Mode!
how-migration-hooks-work-in-odoo-19.jpg
By: Muhammed Fahis V P

How Migration Hooks Work in Odoo 19

Technical Odoo 19 Odoo Enterprises Odoo Community

Upgrading some Odoo modules is not always about updating their versions only. For example, if you have changed the name of a field, added a new column and need to fill it up with data or altered your models structure somehow, your upgrade will not work properly or even worse it will not fail but all your data will get lost. This is where migration hooks come into play.

The current blog post is about everything related to migration hooks, what they are, when and how Odoo imports them, and finally how to develop them properly.

What Are Migration Hooks?

Migration scripts are Python files that Odoo executes automatically whenever an upgrade takes place in a particular module. These scripts are placed inside a migrations directory within your module, categorised based on version number. Odoo recognises these scripts, executes them at appropriate times, and feeds them the database cursor and previous version information.

There are two main migration hooks that you'll be using frequently:

pre-migration hook – executed before Odoo upgrades the schema. The database is still in its previous state. Raw SQL can be used in this script.

post-migration hook – executed after the schema upgrade is done. The database contains all the new columns and tables. This is where you can safely use the ORM.

Directory Structure

The migration scripts are stored in a migrations directory inside your module. The name of the directory inside the migrations directory should be exactly the same as the version string in your __manifest__.py file. Otherwise, Odoo won’t execute the migration scripts.

my_module/
+-- migrations/
¦   +-- 19.0.1.0.0/
¦       +-- pre-migrate.py
¦       +-- post-migrate.py
+-- models/
+-- views/
+-- __manifest__.py

For a module that has gone through two versions, the structure looks like this:

my_module/
+-- migrations/
¦   +-- 19.0.1.0.0/
¦   Â¦   +-- pre-migrate.py
¦   Â¦   +-- post-migrate.py
¦   +-- 19.0.2.0.0/
¦       +-- pre-migrate.py
¦       +-- post-migrate.py

Each time you bump the version and run an upgrade, Odoo finds the matching folder and runs whatever scripts are inside it.

Setting the Version in the Manifest

Before anything runs, Odoo checks the version defined in your manifest against what is stored in the database from the last install. If the versions differ, Odoo knows an upgrade is happening and will look for migration scripts.

# __manifest__.py
{
    'name': 'My Module',
    'version': '19.0.2.0.0',
    'depends': ['base'],
    ...
}

A good versioning convention for Odoo modules is [odoo_version].[major].[minor].[patch]. So 19.0.2.0.0 means Odoo 19, second major release of the module. Every time you bump this and run -u, Odoo will look inside the corresponding migrations folder.

The migrate() Function

Every migration script  whether pre or post  must define a function with this exact signature:

def migrate(cr, version):
    ...

Odoo calls this function automatically. The two arguments it passes are:

  • cr — a psycopg2 database cursor. Use this for raw SQL queries.
  • version — a string containing the previous version of the module. On a fresh install (no previous version), this will be None.

The version argument is what lets you distinguish between a fresh install and an actual upgrade. Almost every migration script you write should start with this guard:

def migrate(cr, version):
    if not version:
        return

Without this, your migration logic would run on fresh installs too, which usually causes errors because there is no old data to work with.

Writing a pre-migrate Script

The pre-migrate script runs before the ORM touches the database. That means you cannot use env['my.model'].search(...) here because the model's new structure may not match the database yet. Stick to raw SQL.

Here is an example:

# migrations/19.0.2.0.0/pre-migrate.py
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
    if not version:
        _logger.info("Fresh install detected. Skipping pre-migrate.")
        return
    _logger.info("pre-migrate 19.0.2.0.0: Starting.")
    # Check if the old column exists before touching anything
    cr.execute("""
        SELECT column_name
        FROM information_schema.columns
        WHERE table_name = 'my_model'
          AND column_name = 'old_field_name';
    """)
    old_col_exists = cr.fetchone()
    if not old_col_exists:
        _logger.warning("Column old_field_name not found. Skipping rename.")
        return
    # Add the new column
    cr.execute("""
        ALTER TABLE my_model
        ADD COLUMN IF NOT EXISTS new_field_name VARCHAR;
    """)
    # Copy data across
    cr.execute("""
        UPDATE my_model
        SET new_field_name = old_field_name
        WHERE old_field_name IS NOT NULL;
    """)
    _logger.info("Copied %d rows.", cr.rowcount)
    # Drop the old column
    cr.execute("""
        ALTER TABLE my_model
        DROP COLUMN old_field_name;
    """)
    _logger.info("pre-migrate 19.0.2.0.0: Complete.")

Writing a post-migrate Script

The post-migrate script runs after Odoo has applied all the schema changes. The new columns exist, the new tables exist, and the ORM is in sync with the database. This means you can safely use api.Environment here.

# migrations/19.0.2.0.0/post-migrate.py
import logging
from odoo import api, SUPERUSER_ID
_logger = logging.getLogger(__name__)
def migrate(cr, version):
    if not version:
        _logger.info("Fresh install detected. Skipping post-migrate.")
        return
    _logger.info("post-migrate 19.0.2.0.0: Running data backfill.")
    env = api.Environment(cr, SUPERUSER_ID, {})
    # Set a default value for a newly added field on existing records
    company_contacts = env['custom.contact'].search([
        ('company_type', '=', 'company'),
    ])
    if company_contacts:
        company_contacts.write({'customer_rank': 1})
        _logger.info(
            "Set customer_rank=1 for %d company contact(s).",
            len(company_contacts),
        )
    _logger.info("post-migrate 19.0.2.0.0: Complete.")

Using SUPERUSER_ID is important here. During a migration, security rules and access rights may not be fully initialised yet. Running as superuser avoids permission errors that have nothing to do with your logic.

Also notice the use of .write() on a recordset rather than looping through records one by one. For large datasets this matters one SQL UPDATE is far cheaper than hundreds of individual writes.

Raw SQL vs ORM — When to Use Which

One of the biggest reasons that confuses people regarding migration hooks is this.

Using ORM in the pre-migrate phase can cause some serious problems since the schema on the database table can be different from what is defined in your models. So, you will face column errors or missing errors, etc., if you use env['my.model'] in the pre-migrate phase.

However, in the post-migrate phase, you have the freedom to use both. Using raw SQL queries is useful for bulk operations while using ORM queries will allow access to computed fields, etc.

cr.execute("""
    UPDATE res_partner
    SET customer_rank = 1
    WHERE customer = True
""")
env['res.partner'].search([('customer', '=', True)]).write({'customer_rank': 1})

One thing that should be considered: if you decide to write some data using ORM during the post-migrate phase and right after that use a raw SQL statement to check the writing process, you might end up with outdated data. ORM uses buffering and sends data into the database during special moments. In order to synchronize data between ORM and raw SQL:

env['custom.contact'].flush_model()
cr.execute("SELECT COUNT(*) FROM custom_contact WHERE is_migrated = TRUE")
count = cr.fetchone()[0]
_logger.info("Migrated records: %d", count)

A Full Example: Field Rename Migration

Assume that you will be updating your module from version 19.0.1.0.0 to 19.0.2.0.0. The upgrade will see you renaming the phone_no field on your custom.contact model to mobile_number. You will have lost the contents in phone_no due to absence of migration hooks during the process.

This is how it should have been done.

pre-migrate.py — save the data before the old column disappears:

# migrations/19.0.2.0.0/pre-migrate.py
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
    if not version:
        return
    _logger.info(
        "pre-migrate 19.0.2.0.0: Renaming phone_no ? mobile_number"
    )
    # Safety check -- avoid errors if the script already ran once
    cr.execute("""
        SELECT column_name FROM information_schema.columns
        WHERE table_name = 'custom_contact'
          AND column_name = 'phone_no';
    """)
    if not cr.fetchone():
        _logger.warning("phone_no column not found. Already renamed?")
        return
    cr.execute("""
        ALTER TABLE custom_contact
        ADD COLUMN IF NOT EXISTS mobile_number VARCHAR;
    """)
    cr.execute("""
        UPDATE custom_contact
        SET mobile_number = phone_no
        WHERE phone_no IS NOT NULL;
    """)
    _logger.info("Copied phone_no data for %d row(s).", cr.rowcount)
    cr.execute("ALTER TABLE custom_contact DROP COLUMN phone_no;")
    _logger.info("Dropped old phone_no column.")
    _logger.info("pre-migrate 19.0.2.0.0: Complete.")

post-migrate.py — backfill the new fields that were added in this version:

# migrations/19.0.2.0.0/post-migrate.py
import logging
from odoo import api, SUPERUSER_ID
_logger = logging.getLogger(__name__)
def migrate(cr, version):
    if not version:
        return
    _logger.info("post-migrate 19.0.2.0.0: Running.")
    env = api.Environment(cr, SUPERUSER_ID, {})
    # Backfill customer_rank for all existing company contacts
    companies = env['custom.contact'].search([('company_type', '=', 'company')])
    companies.write({'customer_rank': 1})
    _logger.info("Set customer_rank=1 for %d company record(s).", len(companies))
    # Mark all existing records as migrated and write a note
    all_contacts = env['custom.contact'].search([])
    for contact in all_contacts:
        contact.write({
            'is_migrated': True,
            'migration_note': (
                f"Migrated from v{version} to 19.0.2.0.0. "
                f"Field renamed: phone_no ? mobile_number."
            ),
        })
    # Flush ORM writes before running raw SQL summary
    env['custom.contact'].flush_model()
    cr.execute("""
        SELECT
            COUNT(*)                                           AS total,
            COUNT(*) FILTER (WHERE is_migrated = TRUE)        AS migrated,
            COUNT(*) FILTER (WHERE mobile_number IS NOT NULL)  AS has_mobile
        FROM custom_contact;
    """)
    row = cr.fetchone()
    _logger.info(
        "SUMMARY -- total=%s | migrated=%s | has_mobile=%s", *row
    )
    _logger.info("post-migrate 19.0.2.0.0: Complete.")

Handling XML ID Renames

If you rename an external ID (XML ID) in your data files between versions, Odoo will not automatically connect the new ID to the existing database record. Instead, it will create a duplicate. To prevent this, update ir_model_data in your pre-migrate script:

def migrate(cr, version):
    if not version:
        return
    cr.execute("""
        UPDATE ir_model_data
        SET name = 'new_xml_record_id'
        WHERE module = 'my_module'
          AND name = 'old_xml_record_id';
    """)
    if cr.rowcount:
        _logger.info(
            "Renamed XML ID old_xml_record_id ? new_xml_record_id (%d row).",
            cr.rowcount,
        )

This runs before Odoo processes your data XML, so by the time it reads your updated data file, it will find the correct existing record and update it instead of creating a new one.

Logging Inside Migration Scripts

Adding proper logging to migration scripts is not optional it is how you know something went wrong during a production upgrade at 2 am. Use Python's standard logging module:

import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
    if not version:
        return
    _logger.info("Starting migration for version %s", version)
    cr.execute("UPDATE sale_order SET state = 'draft' WHERE state IS NULL")
    _logger.info("Fixed NULL state on %d sale orders.", cr.rowcount)
    _logger.info("Migration complete.")

When you run the upgrade, these lines show up in the terminal output prefixed with the module name and log level. You can filter the output by grepping for your module name:

python odoo-bin -d your_db -u my_module --stop-after-init 2>&1 | grep my_module

Running the Migration

Once your scripts are in place and your manifest version is bumped, run the upgrade from the command line:

python odoo-bin -d your_database -u my_module --stop-after-init

What Odoo does in order:

Odoo runs in this order:

  1. Detects the version change
  2. Runs pre-migrate.py
  3. Applies schema changes
  4. Loads data files
  5. Runs post-migrate.py

When you skip one or more versions, for example, from 19.0.1.0.0 to 19.0.3.0.0, then Odoo executes all the scripts in between, starting from 19.0.2.0.0 to

Things to Keep in Mind

Always use the if not version: return guard. Otherwise, your migration code will be executed on newly installed databases and will likely fail because there won’t be anything to migrate from.

Test on a database copy first. Always run your migration script against a copy of the database first before running it against a production database. Even well-written scripts can have edge cases that only show up with real data.

Do not use the ORM in pre-migrate. because the schema might not match the models' declarations in pre-migrate. Use raw SQL here.

Batch large updates. If there are many entries (let's say hundreds of thousands) that need to be updated, avoid running an ORM update on all those rows in one go; batch the operation by using SQL LIMIT/OFFSET clauses.

Migration scripts run only once. Each migration script is tied to a particular module version and cannot be executed after that version number is recorded in ir_module_module.

The power of migration hooks allows you to be completely in control of how your data is handled during any updates made by the module. Without these hooks, the renaming of fields loses the data associated with it; new mandatory fields destroy the existing records, while XML-ID change creates duplicate entries for all of your records.

It becomes very easy to follow this pattern after you learn it, once you place your data preservation SQL code into the pre-migrate.py file, ORM code into post-migrate.py file, always check the version before proceeding, and log all actions taken in both files.

To read more about What are the Different types of hooks in Odoo 19, refer to our blog What are the Different types of hooks in Odoo 19.


Frequently Asked Questions

What happens if I omit the if not version: return guard?

Migration will execute on fresh installs as well. There won't be any data or tables according to the previous version. In other words, all your migration code will fail with an SQL error (trying to read from nonexistent columns) or do absolutely nothing (which is still incorrect). The guard is necessary.

Is it OK to access env and the ORM from pre-migrate.py?

Strictly speaking, you can construct an api.Environment object in pre-migration; however, this is discouraged. Since the schema is not yet updated to match the model definition, accessing any fields defined in the models may cause problems. Stick with raw SQL here.

Are Odoo migration scripts executed sequentially when skipping a version?

Yes. If you have skipped any particular migration in your module migration process, for example, migrating from 19.0.1.0.0 to 19.0.3.0.0, the migration scripts will be performed sequentially for both 19.0.2.0.0/ and 19.0.3.0.0/.

What if the migration script execution fails halfway through?

Migration script executions in Odoo are enclosed within a transaction. In the event that an error occurs during the migration process and hence an exception is raised by any function in the script, the transaction is reversed. This ensures that the migration does not corrupt the existing database, but it will require starting afresh after resolving the problem in the script.

If you need any assistance in odoo, we are online, please chat with us.



0
Comments



Leave a comment



Recent Posts

whatsapp_icon
location

Calicut

Cybrosys Technologies Pvt. Ltd.
Neospace, Kinfra Techno Park
Kakkancherry, Calicut
Kerala, India - 673635

location

Kochi

Cybrosys Technologies Pvt. Ltd.
1st Floor, Thapasya Building,
Infopark, Kakkanad,
Kochi, India - 682030.

location

Bangalore

Cybrosys Techno Solutions
The Estate, 8th Floor,
Dickenson Road,
Bangalore, India - 560042

Send Us A Message