Odoo Multi-Version Migration: 11.0 → 18.0 (OpenUpgrade)

This page documents the automated migration chain used to bring the luxy production database from Odoo 11.0 all the way to 18.0 using OCA OpenUpgrade, one major version at a time. It covers prerequisites, project layout, how to run a stage, and every non-obvious fix that was needed along the way — so a future run (or a run against a different database on this same chain) doesn't have to rediscover them.

Odoo (and OpenUpgrade) only supports migrating one major version at a time — there is no direct 11.0 → 18.0 path. The chain implemented here is:

11.0 -> 12.0 -> 13.0 -> 14.0 -> 15.0 -> 16.0 -> 17.0 -> 18.0

Each hop:

  1. Duplicates the previous stage's database (cheap, via CREATE DATABASE … WITH TEMPLATE).
  2. Runs that version's pre-migration SQL fixes (only needed for the early, hand-written-SQL stages).
  3. Runs the real OpenUpgrade module migration (odoo -u all) inside that version's own container.
  4. Auto-recovers from a library of known, safe failure signatures (see Generic auto-recovery patterns).
  5. Applies any additional known, stage-specific fixes.
  6. Cleans up modules left in a broken state, clears the compiled asset cache, and reports the final module state.

All of this is orchestrated by a single script: run_upgrade_stage.sh.

  • Docker + Docker Compose (v2, docker compose not docker-compose).
  • A single shared PostgreSQL instance (this project uses postgres:14.5 for every version — Odoo talks to it over the network, the server version doesn't need to match the Odoo version).
  • Disk space: each version's Docker image is ~1.3–2.3GB, growing with version. Budget at least 5GB free before starting a new stage; the full chain (11 through 18, all images built) needs tens of GB. See Disk space management.
  • Per-version OpenUpgrade checkout under <version>/openupgrade — either the old per-module migrations/ convention (11.0–13.0) or the centralized openupgrade_scripts/ + openupgrade_framework convention (14.0+). These are normally provided by a sibling project's init.sh (clones OCA/OpenUpgrade at the matching branch).
  • bash 5.x on the host running run_upgrade_stage.sh (uses set -euo pipefail; see the note about local var under set -u in Gotchas worth remembering).
  • psql access to the shared postgres container from the host (script shells out via docker exec postgres psql).
oca_openupgrade/
├── docker-compose.yaml          # one service per Odoo version + shared postgres + nginx-proxy
├── run_upgrade_stage.sh         # the master automation script (see below)
├── pre_migration_cleanup.sql    # 11->12 pre-migration SQL fixes
├── post_migration_cleanup.sql   # 11->12 post-migration SQL fixes (asset cache, etc.)
├── migration12to13.sql          # 12->13 pre-migration SQL fixes
├── migration13to14_new.sql      # 13->14 pre-migration SQL fixes
├── upgrade_logs/                # every stage's driver + per-attempt logs
├── 11.0/ … 18.0/                # per-version:
│   ├── config/odoo.conf         #   Odoo config (addons_path, db creds, admin_passwd)
│   ├── addons/                  #   extra/private addons for that version
│   ├── openupgrade/             #   OCA OpenUpgrade checkout for that version
│   └── odooXX-data/             #   bind-mounted /var/lib/odoo (filestore, sessions)
└── fix_corrupted_view_translations.sql        # standalone fixes, portable to any server
└── cleanup_leftover_uninstalled_modules.py     # (see section 7)
  • One service per version: odoo11odoo18 (odoo19 defined but not yet used).
  • A single postgres service shared by every version.
  • Only the versions currently being worked on need to be uncommented — once a stage is complete and verified, its container/image can be stopped and removed to save disk (see section 8). The corresponding block in docker-compose.yaml can be commented back out; the database itself lives in Postgres independently of the container.
  • odoo11 uses the stock odoo:11.0 image (no OpenUpgrade lib needed at that version's image level — OpenUpgrade there is just an addons-path checkout). odoo12+ use a custom Dockerfile that installs openupgradelib on top of the base Odoo image.

Two config bugs recur across versions and are worth checking every time a new version's container is brought up for the first time:

  1. A stray space in addons_path (e.g. /mnt/openupgrade , /mnt/extra-addons). Odoo's HTTP static-file bootstrap (load_addons() / the Application.statics property in odoo/http.py) does a raw, unguarded os.listdir() over every addons_path entry. A trailing-space entry that doesn't exist as a real directory throws an uncaught exception that silently disables all static file serving for the life of the process — the page loads with zero CSS/JS, and every static asset 404s, with no error in the Odoo log beyond the very first request. Fix: remove the stray space/comma so every entry is a real, listable directory.
  2. The same class of bug: a nonexistent addons_path entry entirely (e.g. a leftover reference to a since-uninstalled OCA module's own repo path). Same symptom, same fix — remove the dead entry.
docker exec -u root <container> sed -i 's#/mnt/openupgrade ,#/mnt/openupgrade,#' /etc/odoo/odoo.conf
docker restart <container>

Usage:

./run_upgrade_stage.sh <stage> <src_db> <dst_db>
 
# Stages: 11to12 | 12to13 | 13to14 | 14to15 | 15to16 | 16to17 | 17to18
 
./run_upgrade_stage.sh 11to12 luxy_prod   luxy_11to12
./run_upgrade_stage.sh 12to13 luxy_11to12 luxy_12to13
./run_upgrade_stage.sh 13to14 luxy_12to13 luxy_13to14
./run_upgrade_stage.sh 14to15 luxy_13to14 luxy_14to15
./run_upgrade_stage.sh 15to16 luxy_14to15 luxy_15to16
./run_upgrade_stage.sh 16to17 luxy_15to16 luxy_16to17
./run_upgrade_stage.sh 17to18 luxy_16to17 luxy_17to18

Pipeline run by main() for every stage:

  1. ensure_container_env — filestore ownership (chown odoo:odoo /var/lib/odoo), the odoo.openupgrade stub for 11–13 (see 4.1), and any stage-specific container source patches (see each stage's notes below).
  2. recreate_db — terminates connections, DROP DATABASE, CREATE DATABASE … WITH TEMPLATE <src_db>.
  3. run_pre_sql — the stage's hand-written pre-migration SQL file, if any (only 11→12, 12→13, 13→14 have one).
  4. apply_known_fixes_<stage> — proactive, stage-specific SQL fixes applied before the migration starts (see each stage's section).
  5. run_migration — the actual odoo -u all –stop-after-init run, in a retry loop (up to 60 attempts) that calls try_generic_recovery() after every failure (section 5).
  6. On success: cleanup_stuck_modules, cleanup_leftover_uninstalled_modules, fix_corrupted_view_translations (sections 6–7), run_post_sql, clear_asset_cache, report_state.
  • addons_path ordering bug: the 11/12 images run vanilla Odoo with the OpenUpgrade addon tree mounted alongside it. Odoo resolves each module name to a single winning addons_path directory (used for both code *and* migrations, no fallback). Community addons (repair, sale, stock, account, …) must resolve to /mnt/openupgrade/addons first; core modules (base, etc.) must resolve to /mnt/openupgrade/odoo/addons first, so the base module's own pre-migration (which does the module-rename/merge bookkeeping many other modules depend on) actually runs.
  • odoo.openupgrade stub: the base module's OpenUpgrade code imports odoo.openupgrade — a small pair of helper files that doesn't exist in vanilla dist-packages Odoo. Copied in once per container, idempotent.
  • point_of_sale demo data crash: point_of_sale/12.0.1.0.1/noupdate_changes.xml writes default_code on an existing demo product, triggering a KeyError deep in the computed-field registry. Fix: drop that one <field> line.
  • account_asset vs account_asset_management (OCA): mutually exclusive; force account_asset to uninstalled so the OCA migration can take over its tables.
  • Premature renames reversed: the original pre-migration SQL pre-emptively renamed hr_holidays(_status) and procurement_rule as a workaround for the addons_path bug above. Once that root cause was fixed, those premature renames had to be reversed so the real OpenUpgrade scripts could do the full transformation themselves.
  • Space-corrupted xmlids: a data-quality artifact (xml_id name containing a literal space) that breaks lookups the target version's data files expect by the correct (underlying) name. Generic fix, reused every stage after.
  • toggle_active anchor injection: Odoo 13 replaced the old <button name=“toggle_active”> on res.partner/hr.employee/product.* forms with a web_ribbon widget, but several still-current modules ship views that xpath-anchor off that button. A minimal invisible one is re-injected into 5 core view files via an lxml-based helper.
  • Stale xpath removal: the product views shipped by sale and by stock_account each xpath onto elements (//group[@name='invoicing'], a button referenced by resolved action id) that no longer reliably exist/resolve in this hybrid environment. Removed just those xpath blocks via regex.
  • Lang code renames (ar_AAar_001, filfil_PH) and 25 module-category renames applied pre-emptively, because the base module's own bootstrap data (loaded *before* any migration script runs) already references the new names/codes.
  • Image field renames (imageimage_1920, etc.) applied the same way, for the same bootstrap-timing reason.
  • account_move classification gap (the big one): the account module's own account_invoiceaccount_move migration was found to silently not run — every account_move row ended up as generic type entry, making every invoice/bill invisible in the Invoicing app despite the underlying accounting data being intact. Fixed by restoring account_move.type from account_invoice.type via the surviving account_invoice.move_id link, for every invoice that was actually posted.
  • 432 draft/cancelled invoices never had a posted move in the old system, so the fix above didn't touch them. A separate one-off Python script rebuilt these as proper account.move records (with lines and taxes) via the ORM, once the user asked for it — 427 of 432 succeeded (5 had zero original line items, nothing to reconstruct).
  • 7 modules improperly “installed” with no real v13 code (account_voucher, account_cancel, crm_phone_validation, decimal_precision, document, web_environment_ribbon, web_settings_dashboard): a plain SQL state flip to uninstalled is not enough — their views/menus/actions linger and crash the UI (e.g. an orphaned “Vendor Bills” action still pointing at the long-gone account.voucher model). The correct fix is a real uninstall through the ORM (button_immediate_uninstall() via odoo shell), which cascades and removes everything tracked under that module's xmlids in dependency order. This became the standard pattern for every later stage too.
  • From 14.0 onward, OpenUpgrade switches from the old per-module migrations/ convention to a centralized openupgrade_scripts/scripts tree, located via –upgrade-path. It also ships an openupgrade_framework server-wide module that patches core ORM/loading behaviour (view-inheritance handling among other things) — required via –load=web,openupgrade_framework. Without it, migrations run but core patches are missing, causing otherwise-unexplained QWeb ParseErrors.
  • mass_mailing table-rename bug: a hand-patched (non-upstream) pre-migration.py in this OpenUpgrade checkout handled the mail_mass_mailingmailing_mailing table rename but was missing the analogous mail_mass_mailing_listmailing_list rename. Without it, the ORM auto-creates an empty mailing_list table, the rel-table column rename proceeds anyway, and check_foreign_keys() fails (old list ids referencing the new, empty table). Fixed by adding the missing _handle_mailing_list_table_rename function, mirroring the existing one.
  • Multi-company picking-type mismatches: several stock.picking.type rows had a company_id that didn't match their own warehouse's company (legacy from an earlier company split/archive — an “archived” 2ES warehouse under a different company than its picking types). Invisible until a v14 data file (a function in point_of_sale called _create_missing_pos_picking_types) writes one of them, triggering _check_company(). Fix: realign company_id to the warehouse's company — but only where doing so doesn't create a *new* mismatch against a sequence genuinely shared across different companies' warehouses (in which case, null out that sequence's company_id instead, matching Odoo's own convention for company-agnostic shared resources).
  • l10n_dz ended up stuck with no installable code — cleaned up via the standard ORM-uninstall pattern.
  • Module rename collision: payment_ingenicopayment_ogone, but a stale, empty payment_ogone row already existed from an earlier hop. Both uninstalled, zero attached data — safe to clear the collision and let the rename proceed. Generalized into a new recovery pattern (see 5, pattern a2).
  • hr_holidays v15 allocation constraint: a new _check_allocation_id constraint requires every “employee”-type leave request to have a matching hr.leave.allocation — but that requirement is new in v15, and this business's historical data has far more validated leave requests than matching allocations for several leave types (formal allocation tracking was simply never enforced in the old system). Fixed by pre-emptively — and, for any type the heuristic misses, generically via a recovery pattern — setting requires_allocation='no' for the affected leave type(s).
  • Non-idempotent openupgrade_legacy_* columns: several migration scripts call openupgradelib's copy_columns() to stash an old column under a new name before transforming it — not retry-safe (no IF NOT EXISTS guard). A prior attempt that got far enough already created the column; the next retry's raw ADD COLUMN then crashes on DuplicateColumn. Generic fix: drop the stale column and let copy_columns() recreate it.
  • account.payment constraint firing before its own backfill: _check_payment_method_line_id requires that field to be set on every payment, but it's only backfilled by the account module's own end-migration.py (which explicitly runs last). The constraint fires earlier anyway, during an unrelated module's registry-init flush — a false positive against records still awaiting that backfill. Patched the constraint to skip while self.env.registry.ready is still False.
  • Orphaned duplicate view bug (root-caused here, fixed for good): clearing a colliding ir_model_data row (recovery pattern a) only removed the *pointer* — the underlying, now-orphaned ir.ui.view row was never deleted, and Odoo still combines it into its model's inheritance chain regardless of xmlid tracking. Confirmed: 25 such orphans had silently accumulated, each a stale duplicate of a properly-tracked view recreated under the same xmlid, and one of them (a stale v14-era xpath) crashed the Contacts page. Fixed generically: pattern (a) now also deletes the underlying ir_ui_view row when the colliding record's model is ir.ui.view.
  • Duplicate resource_calendar_attendance rows: exact-content duplicates (same calendar/day/hours/name, different id) accumulated somewhere earlier in the chain, tripping v16's _check_overlap (“Attendances can't overlap”) — not a real scheduling conflict. Generic fix: de-duplicate, keeping the lower id.
  • google_drive/google_spreadsheet stuck with a missing model class: marked “installed” but the standard ORM-uninstall path itself crashed (KeyError: 'google.drive.config' — the model genuinely wasn't registered in the live registry to cascade through). Cleaned up via manual, careful SQL cascade instead (verified zero real business data attached first — a single inactive demo config row).
  • Repair module (the toughest single issue in the whole chain): v17's backfill of repair.order picking types/locations crashed on NOT NULL violations from two separate angles:
    1. A warehouse whose repair_type_id was already set before the creation step ran (exact trigger not fully traced — confirmed via debug instrumentation that only 3 of 4 warehouses got the expected creation log line), so its `default_remove_location_dest_id`-derived value ended up NULL.
    2. ~238 repair orders whose source location_id isn't tied to any warehouse at all (e.g. the global “Scrapped” virtual location) — these never matched the migration's JOIN at all and kept pointing at a temporary placeholder that later got deleted, cascading a SET NULL onto NOT NULL columns.
    3. Fixed with a full COALESCE fallback chain (falling back to the warehouse's own stock location, and ultimately to the repair order's own location) plus switching the update's JOINs to LEFT JOIN so every row is covered — patched directly into the openupgrade script file.
  • Analytic plans: v17's analytic-accounting overhaul expects a default “Projects” analytic plan pointed to by the analytic.project_plan system parameter. This environment only had a “Legacy” plan. Pre-created the “Projects” plan, the config parameter, and backfilled parent_path for the new materialized-path tree column (from prior manual-migration notes on another database of the same version jump).
  • arch_db became jsonb around this version — the existing “stale xpath” recovery pattern's SQL (arch_db LIKE …) broke with a Postgres type error. Fixed with an explicit ::text cast.
  • Migration itself succeeded cleanly on the first attempt — no new migration-time issues.
  • Corrupted view translations discovered post-migration (traced back to the 14→15 hop): a handful of views have a translation key (fr_FR, ar_SY, …) whose value is a bare translated string (e.g. a button's label) instead of the view's translated XML arch — a stale/mismatched ir.translation row incorrectly consolidated into the wrong jsonb slot, most likely during the old ir_translation → jsonb migration. Combining that view for a user on that locale then crashes outright (XMLSyntaxError: Start tag expected). Fixed by stripping any jsonb key that isn't XML-shaped (falls back to a working language). See section 7 for the standalone fix.
  • Modules marked “uninstalled” since the very first (11→12) stage, still leaking live data: account_budget_oca, anonymization, log_forwarded_for_ip, website_mail, survey, survey_crm. Their views/menus/etc. were never actually cleaned up and silently rode along through all 7 subsequent migration hops, undetected until a French-locale user opened Settings and a lingering, still-active view from account_budget_oca (named res_config_settings_view_form) had an xpath that no longer matched the current form. Fixed via the same real-ORM-uninstall pattern used throughout. See section 7.

try_generic_recovery() inspects the last failed attempt's log for a known-safe signature and applies a fix automatically, so the retry loop can just try again. In order of appearance in the script:

Pattern Signature Fix
a0 DuplicateColumn on an openupgrade_legacy_* backup column Drop the column; copy_columns() recreates it cleanly
a1b “Attendances can't overlap” / “présences ne peuvent pas se chevaucher” De-duplicate exact-copy resource_calendar_attendance rows
a1 “Could not find an allocation of type X…” UPDATE hr_leave_type SET requires_allocation='no' for that type
a ir_model_data_module_name_uniq_index violation Delete the colliding xmlid row (and the underlying ir.ui.view row if that's the model)
a2 ir_module_module_name_uniq violation Delete the stale duplicate module row, if uninstalled with no attached data
b ir_model_relation_model_fkey Delete the obsolete model's own m2m relation-bookkeeping row
c ir_cron_ir_actions_server_id_fkey Delete the stale cron pointing at the obsolete server action
d Any other DELETE blocked by a real FK Protect the record (noupdate=true) instead of forcing the delete — assume it's real, still-referenced data
e A view write fails validation mid-load (misattributed to the wrong view_id) Search all views for the offending field/element; delete if genuinely orphaned (not defined in current module source), otherwise protect via noupdate

The guiding philosophy throughout: never force-delete something that might be real data — protect it (noupdate=true) and move on. Every “detected orphan → delete” case was explicitly verified (module source check, zero-attached-data check, or an exact-duplicate check) before being made automatic.

6. Post-migration cleanup (runs after every successful stage)

  • cleanup_stuck_modules: modules left in a state other than installed/uninstalled/uninstallable (typically to upgrade) because OpenUpgrade's rename table points them at code that isn't actually present. A plain state flip is not enough — real ORM uninstall via odoo shell + button_immediate_uninstall().
  • cleanup_leftover_uninstalled_modules *(added after the 17→18 discovery)*: sweeps for modules already marked uninstalled that still have live ir_model_data-tracked records — a state cleanup_stuck_modules never checks (it only looks at *non*-clean states). Same real-ORM-uninstall fix.
  • fix_corrupted_view_translations *(added after the 17→18 discovery)*: strips any ir_ui_view.arch_db jsonb key whose value isn't XML-shaped. No-ops harmlessly on pre-jsonb (11→12 through 13→14) stages.
  • clear_asset_cache: deletes compiled web.assets* ir_attachment rows and restarts the container, so the next page load recompiles fresh bundles.

Two of the fixes discovered on 17→18 are generic (auto-detect, no hardcoded database or module names) and safe to run against any Odoo 18 (or later jsonb-arch) database, independent of this project's docker-compose setup:

BEGIN;
 
UPDATE ir_ui_view v
SET arch_db = (
  SELECT jsonb_object_agg(kv.key, kv.value)
  FROM jsonb_each_text(v.arch_db) AS kv
  WHERE kv.value ~ '^\s*(<\?xml|<)'
)
WHERE EXISTS (
  SELECT 1 FROM jsonb_each_text(v.arch_db) AS kv2
  WHERE kv2.value !~ '^\s*(<\?xml|<)'
);
 
COMMIT;

Run with:

docker exec -i postgres psql -U odoo -d <DB_NAME> < fix_corrupted_view_translations.sql
env.cr.execute("""
    SELECT DISTINCT m.name
    FROM ir_module_module m
    JOIN ir_model_data d ON d.module = m.name
    WHERE m.state = 'uninstalled'
""")
leftover_names = [r[0] for r in env.cr.fetchall()]
 
if leftover_names:
    mods = env['ir.module.module'].search([('name', 'in', leftover_names)])
    mods.write({'state': 'installed'})
    mods.button_immediate_uninstall()
    env.cr.commit()

Run with:

docker exec -i <ODOO_CONTAINER> odoo shell -d <DB_NAME> --no-http < cleanup_leftover_uninstalled_modules.py

Run the SQL fix first, then the Python one, then restart the container.

Each version's Docker image is 1.3–2.3GB; the filestore/data directories add up too. Once a stage is complete and verified:

  • The container and image for the now-obsolete version can be removed (docker rm, docker rmi) — the migrated database itself is untouched, since it lives in the shared Postgres volume, not in that container.
  • Comment out the corresponding block in docker-compose.yaml (keep it for reference/possible rebuild later).
  • Unused base images pulled just to build a custom image (e.g. a bare odoo:19.0 tag kept around after oca_openupgrade-odoo19 was already built from it) can usually be removed too — Docker's layer store keeps the layers the built image actually uses.
  • The old version's data directories (filestore, addons) can be removed too for extra space, but this is a judgment call since it's not purely disposable the way the container/image is — confirm with whoever owns the migration before deleting.

Rough numbers from this migration: removing the odoo11/odoo12/odoo13 containers and images freed ~3.8GB; removing a couple of unused base image tags freed another ~2.5GB.

  • local var under set -u: in bash, declaring local a b c and then only conditionally assigning b/c in one branch — if that branch isn't taken, referencing $b later throws “unbound variable” under set -u, even though local alone is often assumed to initialize to empty string. Always give local declarations an explicit =“” if any branch might skip assigning them.
  • A “WITH … UPDATE” CTE only scopes to that one statement. Two separate UPDATE statements in the same script each need their own WITH clause if they both need a CTE — a CTE defined for the first doesn't carry over to the second.
  • Bind-mount writes from the host don't always propagate cleanly into the container on this host (editor rename-based saves in particular). Use docker exec -i <container> sh -c 'cat > path' < hostfile instead of a host-side write when patching a container source file.
  • Odoo commits per-module as it goes during -u all — a crash late in the run does not roll back schema/data changes from modules that already finished. This is why retries resume mid-way rather than replaying everything, and why a fix applied mid-run (e.g. dropping a stale column) sticks even if a *later* module then fails again.
  • A failed attempt's log does roll back the specific transaction that was open at the point of failure — so a newly-added column from that same failing statement won't be visible for inspection afterward, even though earlier, already-committed schema changes will be.

Nadir Habib 2026/08/29 20:17

  • elosys/database_migration.txt
  • Last modified: 2026/08/29 20:19
  • by nadir