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.
1. Overview
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:
- Duplicates the previous stage's database (cheap, via
CREATE DATABASE … WITH TEMPLATE). - Runs that version's pre-migration SQL fixes (only needed for the early, hand-written-SQL stages).
- Runs the real OpenUpgrade module migration (
odoo -u all) inside that version's own container. - Auto-recovers from a library of known, safe failure signatures (see Generic auto-recovery patterns).
- Applies any additional known, stage-specific fixes.
- 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.
2. Prerequisites
- Docker + Docker Compose (v2,
docker composenotdocker-compose). - A single shared PostgreSQL instance (this project uses
postgres:14.5for 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-modulemigrations/convention (11.0–13.0) or the centralizedopenupgrade_scripts/+openupgrade_frameworkconvention (14.0+). These are normally provided by a sibling project'sinit.sh(clonesOCA/OpenUpgradeat the matching branch). - bash 5.x on the host running
run_upgrade_stage.sh(usesset -euo pipefail; see the note aboutlocal varunderset -uin Gotchas worth remembering). psqlaccess to the shared postgres container from the host (script shells out viadocker exec postgres psql).
3. Project Structure
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)
3.1 docker-compose.yaml
- One service per version:
odoo11…odoo18(odoo19 defined but not yet used). - A single
postgresservice 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.yamlcan be commented back out; the database itself lives in Postgres independently of the container. odoo11uses the stockodoo:11.0image (no OpenUpgrade lib needed at that version's image level — OpenUpgrade there is just an addons-path checkout).odoo12+ use a customDockerfilethat installsopenupgradelibon top of the base Odoo image.
3.2 Per-version odoo.conf
Two config bugs recur across versions and are worth checking every time a new version's container is brought up for the first time:
- A stray space in
addons_path(e.g./mnt/openupgrade , /mnt/extra-addons). Odoo's HTTP static-file bootstrap (load_addons()/ theApplication.staticsproperty inodoo/http.py) does a raw, unguardedos.listdir()over everyaddons_pathentry. 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. - The same class of bug: a nonexistent
addons_pathentry 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>
3.3 run_upgrade_stage.sh
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:
ensure_container_env— filestore ownership (chown odoo:odoo /var/lib/odoo), theodoo.openupgradestub for 11–13 (see 4.1), and any stage-specific container source patches (see each stage's notes below).recreate_db— terminates connections,DROP DATABASE,CREATE DATABASE … WITH TEMPLATE <src_db>.run_pre_sql— the stage's hand-written pre-migration SQL file, if any (only 11→12, 12→13, 13→14 have one).apply_known_fixes_<stage>— proactive, stage-specific SQL fixes applied before the migration starts (see each stage's section).run_migration— the actualodoo -u all –stop-after-initrun, in a retry loop (up to 60 attempts) that callstry_generic_recovery()after every failure (section 5).- On success:
cleanup_stuck_modules,cleanup_leftover_uninstalled_modules,fix_corrupted_view_translations(sections 6–7),run_post_sql,clear_asset_cache,report_state.
4. Stage-by-Stage Notes
4.1 11 → 12
- 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_pathdirectory (used for both code *and* migrations, no fallback). Community addons (repair, sale, stock, account, …) must resolve to/mnt/openupgrade/addonsfirst; core modules (base, etc.) must resolve to/mnt/openupgrade/odoo/addonsfirst, so thebasemodule's own pre-migration (which does the module-rename/merge bookkeeping many other modules depend on) actually runs. odoo.openupgradestub: thebasemodule's OpenUpgrade code importsodoo.openupgrade— a small pair of helper files that doesn't exist in vanilladist-packagesOdoo. Copied in once per container, idempotent.- point_of_sale demo data crash:
point_of_sale/12.0.1.0.1/noupdate_changes.xmlwritesdefault_codeon an existing demo product, triggering aKeyErrordeep in the computed-field registry. Fix: drop that one<field>line. account_assetvsaccount_asset_management(OCA): mutually exclusive; forceaccount_assettouninstalledso the OCA migration can take over its tables.- Premature renames reversed: the original pre-migration SQL pre-emptively renamed
hr_holidays(_status)andprocurement_ruleas 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
namecontaining a literal space) that breaks lookups the target version's data files expect by the correct (underlying) name. Generic fix, reused every stage after.
4.2 12 → 13
- toggle_active anchor injection: Odoo 13 replaced the old
<button name=“toggle_active”>onres.partner/hr.employee/product.*forms with aweb_ribbonwidget, 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 anlxml-based helper. - Stale xpath removal: the product views shipped by
saleand bystock_accounteach 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_AA→ar_001,fil→fil_PH) and 25 module-category renames applied pre-emptively, because thebasemodule's own bootstrap data (loaded *before* any migration script runs) already references the new names/codes. - Image field renames (
image→image_1920, etc.) applied the same way, for the same bootstrap-timing reason. account_moveclassification gap (the big one): theaccountmodule's ownaccount_invoice→account_movemigration was found to silently not run — everyaccount_moverow ended up as generic typeentry, making every invoice/bill invisible in the Invoicing app despite the underlying accounting data being intact. Fixed by restoringaccount_move.typefromaccount_invoice.typevia the survivingaccount_invoice.move_idlink, 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.moverecords (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 touninstalledis not enough — their views/menus/actions linger and crash the UI (e.g. an orphaned “Vendor Bills” action still pointing at the long-goneaccount.vouchermodel). The correct fix is a real uninstall through the ORM (button_immediate_uninstall()viaodoo 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.
4.3 13 → 14
- From 14.0 onward, OpenUpgrade switches from the old per-module
migrations/convention to a centralizedopenupgrade_scripts/scriptstree, located via–upgrade-path. It also ships anopenupgrade_frameworkserver-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 QWebParseErrors. - mass_mailing table-rename bug: a hand-patched (non-upstream)
pre-migration.pyin this OpenUpgrade checkout handled themail_mass_mailing→mailing_mailingtable rename but was missing the analogousmail_mass_mailing_list→mailing_listrename. Without it, the ORM auto-creates an emptymailing_listtable, the rel-table column rename proceeds anyway, andcheck_foreign_keys()fails (old list ids referencing the new, empty table). Fixed by adding the missing_handle_mailing_list_table_renamefunction, mirroring the existing one. - Multi-company picking-type mismatches: several
stock.picking.typerows had acompany_idthat 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 inpoint_of_salecalled _create_missing_pos_picking_types) writes one of them, triggering_check_company(). Fix: realigncompany_idto 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'scompany_idinstead, matching Odoo's own convention for company-agnostic shared resources). l10n_dzended up stuck with no installable code — cleaned up via the standard ORM-uninstall pattern.
4.4 14 → 15
- Module rename collision:
payment_ingenico→payment_ogone, but a stale, emptypayment_ogonerow 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_holidaysv15 allocation constraint: a new_check_allocation_idconstraint requires every “employee”-type leave request to have a matchinghr.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'scopy_columns()to stash an old column under a new name before transforming it — not retry-safe (noIF NOT EXISTSguard). A prior attempt that got far enough already created the column; the next retry's rawADD COLUMNthen crashes onDuplicateColumn. Generic fix: drop the stale column and letcopy_columns()recreate it. account.paymentconstraint firing before its own backfill:_check_payment_method_line_idrequires that field to be set on every payment, but it's only backfilled by theaccountmodule's ownend-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 whileself.env.registry.readyis stillFalse.- Orphaned duplicate view bug (root-caused here, fixed for good): clearing a colliding
ir_model_datarow (recovery pattern a) only removed the *pointer* — the underlying, now-orphanedir.ui.viewrow 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 underlyingir_ui_viewrow when the colliding record's model isir.ui.view.
4.5 15 → 16
- Duplicate
resource_calendar_attendancerows: 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_spreadsheetstuck 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).
4.6 16 → 17
- Repair module (the toughest single issue in the whole chain): v17's backfill of
repair.orderpicking types/locations crashed onNOT NULLviolations from two separate angles:- A warehouse whose
repair_type_idwas 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 upNULL. - ~238 repair orders whose source
location_idisn't tied to any warehouse at all (e.g. the global “Scrapped” virtual location) — these never matched the migration'sJOINat all and kept pointing at a temporary placeholder that later got deleted, cascading aSET NULLontoNOT NULLcolumns. - Fixed with a full
COALESCEfallback chain (falling back to the warehouse's own stock location, and ultimately to the repair order's own location) plus switching the update'sJOINs toLEFT JOINso 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_plansystem parameter. This environment only had a “Legacy” plan. Pre-created the “Projects” plan, the config parameter, and backfilledparent_pathfor the new materialized-path tree column (from prior manual-migration notes on another database of the same version jump). arch_dbbecamejsonbaround this version — the existing “stale xpath” recovery pattern's SQL (arch_db LIKE …) broke with a Postgres type error. Fixed with an explicit::textcast.
4.7 17 → 18
- 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/mismatchedir.translationrow incorrectly consolidated into the wrong jsonb slot, most likely during the oldir_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 fromaccount_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.
5. Generic auto-recovery patterns
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 thaninstalled/uninstalled/uninstallable(typicallyto 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 viaodoo shell+button_immediate_uninstall().cleanup_leftover_uninstalled_modules*(added after the 17→18 discovery)*: sweeps for modules already markeduninstalledthat still have liveir_model_data-tracked records — a statecleanup_stuck_modulesnever checks (it only looks at *non*-clean states). Same real-ORM-uninstall fix.fix_corrupted_view_translations*(added after the 17→18 discovery)*: strips anyir_ui_view.arch_dbjsonb key whose value isn't XML-shaped. No-ops harmlessly on pre-jsonb (11→12 through 13→14) stages.clear_asset_cache: deletes compiledweb.assets*ir_attachmentrows and restarts the container, so the next page load recompiles fresh bundles.
7. Standalone fixes (portable to any server)
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:
7.1 fix_corrupted_view_translations.sql
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
7.2 cleanup_leftover_uninstalled_modules.py
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.
8. Disk space management
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.0tag kept around afteroca_openupgrade-odoo19was 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.
9. Gotchas worth remembering
local varunderset -u: in bash, declaringlocal a b cand then only conditionally assigningb/cin one branch — if that branch isn't taken, referencing$blater throws “unbound variable” underset -u, even thoughlocalalone is often assumed to initialize to empty string. Always givelocaldeclarations an explicit=“”if any branch might skip assigning them.- A “WITH … UPDATE” CTE only scopes to that one statement. Two separate
UPDATEstatements in the same script each need their ownWITHclause 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' < hostfileinstead 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