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:
- Detects the version change
- Runs pre-migrate.py
- Applies schema changes
- Loads data files
- 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.