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:

  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.

2. Prerequisites

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

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:

  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>

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:

  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.

4. Stage-by-Stage Notes

4.1 11 → 12

4.2 12 → 13

4.3 13 → 14

4.4 14 → 15

4.5 15 → 16

4.6 16 → 17

4.7 17 → 18

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)

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:

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


Nadir Habib 2026/08/29 20:17