Your Odoo Upgrade Is Only as Safe as Your Least-Maintained Third-Party Module
Every Odoo deployment we audit at Octura runs at least 15 third-party modules. The median is closer to 40. Some come from the OCA (Odoo Community Association) — well-maintained, tested, and reviewed. Others were pulled from the Odoo App Store three years ago by a developer who has since left the company. Nobody remembers what half of them do. Nobody knows if they'll survive an upgrade to Odoo 19.
Odoo 19 introduces breaking changes to the ORM, the asset pipeline, and the web framework. Fields that relied on api.multi holdovers are gone. The ir.qweb rendering engine has changed how templates inherit. JavaScript moved further from the legacy widget system toward OWL 2 components. Any third-party module that touches these areas — and most of them do — will break silently or loudly during the upgrade.
This guide covers the full lifecycle: inventorying your third-party modules, building a compatibility matrix, running automated tests, migrating or replacing incompatible modules, and resolving dependency conflicts — with the scripts and checklists we use on every client migration.
We've migrated module stacks ranging from 12 to 85 third-party addons. The companies that treated the module audit as a pre-migration phase — not an afterthought — shipped on schedule. The ones that skipped it spent more time debugging module conflicts than on the actual database upgrade. The difference is process, not luck.
Building a Complete Third-Party Module Inventory for Odoo 19 Migration
Before you can test anything, you need to know exactly what you're running. Odoo doesn't provide a clean "list all third-party modules" command. Modules ship in addons paths, some are installed, some are present but dormant, and some are dependencies of dependencies that nobody explicitly chose. Here's the script we run on every pre-migration audit:
#!/usr/bin/env python3
"""Audit all installed third-party modules against OCA and Odoo core."""
import xmlrpc.client
import csv
import sys
# ── Connection ──
URL = "https://erp.yourcompany.com"
DB = "production"
USERNAME = "admin"
PASSWORD = "your-api-key"
common = xmlrpc.client.ServerProxy(f"{{URL}}/xmlrpc/2/common")
uid = common.authenticate(DB, USERNAME, PASSWORD, {})
models = xmlrpc.client.ServerProxy(f"{{URL}}/xmlrpc/2/object")
# ── Fetch all installed modules ──
modules = models.execute_kw(
DB, uid, PASSWORD, "ir.module.module", "search_read",
[[["state", "=", "installed"]]],
{"fields": ["name", "shortdesc", "author", "installed_version",
"website", "license", "category_id"]}
)
# ── Classify: core vs enterprise vs third-party ──
CORE_AUTHORS = {"Odoo SA", "Odoo S.A.", ""}
OCA_AUTHOR = "Odoo Community Association (OCA)"
inventory = []
for mod in modules:
author = mod.get("author", "")
if author in CORE_AUTHORS:
origin = "core"
elif OCA_AUTHOR in author:
origin = "oca"
else:
origin = "third-party"
inventory.append({
"name": mod["name"],
"description": mod["shortdesc"],
"author": author,
"version": mod["installed_version"],
"license": mod["license"],
"origin": origin,
"category": mod["category_id"][1] if mod["category_id"] else "",
})
# ── Export to CSV ──
with open("module_inventory.csv", "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=inventory[0].keys())
writer.writeheader()
writer.writerows(inventory)
# ── Summary ──
origins = {}
for m in inventory:
origins[m["origin"]] = origins.get(m["origin"], 0) + 1
print(f"Total installed modules: {{len(inventory)}}")
for origin, count in sorted(origins.items()):
print(f" {{origin}}: {{count}}")This produces a CSV with every installed module classified by origin. The output typically looks something like this:
| Origin | Typical Count | Migration Risk | Action Required |
|---|---|---|---|
| Core (Odoo SA) | 80-150 | Low — handled by Odoo's upgrade service | None (automatic) |
| OCA | 10-30 | Medium — most have 19.0 branches, some lag | Check branch availability, run tests |
| Third-Party (App Store / Custom) | 5-20 | High — no guarantees on upgrade timing | Contact vendor, fork, or find alternatives |
Watch for modules with state installed but no matching directory on the filesystem. This happens when someone deletes the addon folder without uninstalling through the UI. Odoo still thinks the module is active and will throw ImportError on restart. Run SELECT name, state FROM ir_module_module WHERE state = 'installed' and cross-reference with the actual addons paths before starting any migration.
Building a Version Compatibility Matrix: OCA, App Store & Custom Modules
Once you have your inventory, the next step is checking whether each module has an Odoo 19 compatible version. This is the most time-consuming part of any migration — and the part most teams skip, only to discover incompatibilities mid-upgrade when the database is already half-migrated.
#!/bin/bash
# Check OCA repo branch availability for Odoo 19.0
# Usage: ./check_oca_branches.sh oca_repos.txt
INPUT_FILE="$1"
BRANCH="19.0"
RESULTS="oca_compatibility.csv"
echo "repo,branch_exists,last_commit,open_prs" > "$RESULTS"
while IFS= read -r repo; do
# Check if 19.0 branch exists
branch_check=$(curl -s -o /dev/null -w "%{{http_code}}" \
"https://api.github.com/repos/OCA/$repo/branches/$BRANCH")
if [ "$branch_check" = "200" ]; then
# Get last commit date on the branch
last_commit=$(curl -s \
"https://api.github.com/repos/OCA/$repo/branches/$BRANCH" \
| python3 -c "import sys,json; \
print(json.load(sys.stdin)['commit']['commit']['committer']['date'][:10])")
# Count open PRs targeting this branch
open_prs=$(curl -s \
"https://api.github.com/repos/OCA/$repo/pulls?base=$BRANCH&state=open" \
| python3 -c "import sys,json; print(len(json.load(sys.stdin)))")
echo "$repo,yes,$last_commit,$open_prs" >> "$RESULTS"
else
echo "$repo,no,n/a,n/a" >> "$RESULTS"
fi
sleep 1 # Rate limiting for GitHub API
done < "$INPUT_FILE"
echo "Results written to $RESULTS"
echo ""
echo "=== Summary ==="
available=$(grep ",yes," "$RESULTS" | wc -l)
missing=$(grep ",no," "$RESULTS" | wc -l)
echo "Branches available: $available"
echo "Branches missing: $missing"For each module category, you need a different strategy:
| Module Status | Strategy | Effort Estimate | Risk Level |
|---|---|---|---|
| OCA — 19.0 branch exists, tests pass | Update requirements.txt, pin commit hash | 30 min per module | Low |
| OCA — 19.0 branch exists, tests fail | Fork, fix, submit PR upstream | 2-8 hours per module | Medium |
| OCA — no 19.0 branch yet | Port from 17.0/18.0 branch, contribute back | 1-3 days per module | Medium-High |
| App Store — vendor offers 19.0 version | Purchase update, test in staging | 1-2 hours per module | Low-Medium |
| App Store — vendor abandoned / no 19.0 | Find OCA alternative, build in-house, or drop | 1-5 days per module | High |
| Custom in-house module | Migrate using pre/post migration scripts | 2-10 days per module | Variable |
Discovering Alternative Modules
When a module has no 19.0 path forward, finding a replacement requires systematic searching. The OCA alone maintains over 250 repositories with 3,000+ modules. Many solve overlapping problems. Our search order:
- OCA repository index — search by functional area on github.com/OCA. Filter by repositories with active 19.0 branches.
- Odoo App Store — filter by Odoo 19 compatibility and sort by downloads. Check the "Last Updated" date — anything older than 6 months is a risk.
- Odoo 19 core features — Odoo absorbs community features into core with every release. The module you installed for Odoo 15 may now be a native feature in 19.
- Build in-house — only after exhausting the above. Custom builds have the highest initial cost and ongoing maintenance burden.
The OCA maintains maintainer-tools, which includes scripts to auto-port modules between Odoo versions. The oca-port tool can automatically migrate a module from 17.0 to 19.0, handling manifest version bumps, __manifest__.py updates, and common API changes. It won't fix everything — but it handles 60-70% of the mechanical work, letting you focus on the logic changes.
Automated Regression Testing for Third-Party Modules in Odoo 19
You cannot manually test 40 modules across all their features. Automated testing is non-negotiable for any migration involving third-party code. The goal is to catch breakage before the upgraded database hits staging — not after users start reporting that the quality control module silently stopped creating inspection records.
name: Test Third-Party Module Compatibility
on:
push:
branches: [19.0-migration]
pull_request:
branches: [19.0-migration]
jobs:
test-modules:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: odoo
POSTGRES_PASSWORD: odoo
POSTGRES_DB: test_odoo
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
strategy:
fail-fast: false
matrix:
module-group:
- "oca_account_financial_tools"
- "oca_stock_logistics_workflow"
- "oca_sale_workflow"
- "custom_modules"
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Odoo 19 and dependencies
run: |
git clone --depth 1 --branch 19.0 \
https://github.com/odoo/odoo.git /tmp/odoo
pip install -r /tmp/odoo/requirements.txt
pip install websocket-client coverage
- name: Clone third-party modules
run: |
# Each module-group maps to a repos.yml config
python scripts/clone_repos.py \
--config repos/${{ matrix.module-group }}.yml \
--target /tmp/third-party
- name: Run tests with coverage
env:
PGHOST: localhost
PGUSER: odoo
PGPASSWORD: odoo
run: |
# Get comma-separated list of modules to test
MODULES=$(python scripts/list_modules.py \
/tmp/third-party/${{ matrix.module-group }})
coverage run /tmp/odoo/odoo-bin \
--addons-path=/tmp/odoo/addons,/tmp/third-party \
-d test_odoo \
-i "$MODULES" \
--test-enable \
--stop-after-init \
--log-level=test
- name: Check test results
if: always()
run: |
coverage report --fail-under=60
coverage xml -o coverage-${{ matrix.module-group }}.xml
- name: Upload coverage
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.module-group }}
path: coverage-${{ matrix.module-group }}.xml The fail-fast: false setting is critical. Without it, one broken module group cancels all other test jobs — and you lose visibility into which modules passed. You want the full picture: 10 module groups tested in parallel, each reporting independently.
We group modules by OCA repository rather than by functional area. This matches how OCA organizes its code and ensures dependency chains stay within a single test job. For example, account_lock_date_update and account_fiscal_year both live in OCA/account-financial-tools and depend on each other — testing them in separate jobs would force you to duplicate the repository clone.
Dependency Resolution Strategy
Third-party modules create dependency chains that Odoo resolves at install time. A single missing dependency can block the installation of 10 other modules. Before running tests, resolve the full dependency graph:
#!/usr/bin/env python3
"""Resolve and validate third-party module dependency chains."""
import ast
import os
import sys
from pathlib import Path
from collections import defaultdict
def parse_manifest(module_path):
"""Parse __manifest__.py and return module metadata."""
manifest_file = module_path / "__manifest__.py"
if not manifest_file.exists():
return None
with open(manifest_file) as f:
return ast.literal_eval(f.read())
def build_dependency_graph(addons_paths):
"""Build a complete dependency graph across all addons paths."""
modules = {}
graph = defaultdict(set) # module -> set of dependencies
for addons_path in addons_paths:
for module_dir in Path(addons_path).iterdir():
if not module_dir.is_dir():
continue
manifest = parse_manifest(module_dir)
if manifest is None:
continue
name = module_dir.name
modules[name] = {
"path": str(module_dir),
"version": manifest.get("version", ""),
"depends": manifest.get("depends", []),
"author": manifest.get("author", ""),
}
for dep in manifest.get("depends", []):
graph[name].add(dep)
return modules, graph
def find_missing_dependencies(modules, graph):
"""Identify dependencies that aren't available in any addons path."""
all_available = set(modules.keys())
missing = {}
for module, deps in graph.items():
for dep in deps:
if dep not in all_available:
if dep not in missing:
missing[dep] = []
missing[dep].append(module)
return missing
# ── Main ──
addons_paths = sys.argv[1:] # Pass all addons paths as arguments
modules, graph = build_dependency_graph(addons_paths)
missing = find_missing_dependencies(modules, graph)
if missing:
print("MISSING DEPENDENCIES (will block installation):")
for dep, needed_by in sorted(missing.items()):
print(f" {dep} <- required by: {', '.join(needed_by)}")
sys.exit(1)
else:
print(f"All dependencies resolved. {len(modules)} modules available.") Never run third-party module tests against a copy of your production database. Use a fresh test database with demo data. Third-party modules with bad uninstall hooks can corrupt data, and you don't want to discover that on a production clone. Run a separate integration test later with anonymized production data once unit tests pass.
Forking, Porting & Contributing: How to Handle Incompatible OCA Modules
When an OCA module doesn't have a 19.0 branch — or the branch exists but tests fail — you have three options. The right choice depends on how critical the module is, how complex the port is, and whether you want to maintain a fork long-term.
Option 1: Port and Contribute Upstream
This is the preferred path. You port the module to 19.0, submit a PR to the OCA repository, and benefit from community review and future maintenance. The oca-port tool handles the mechanical changes:
# Install OCA maintainer tools
pip install oca-port
# Port module from 17.0 to 19.0
# This handles: manifest version, imports, API changes
oca-port --source 17.0 --target 19.0 \
--repo OCA/account-financial-tools \
--module account_lock_date_update
# Review the changes — oca-port creates a local branch
cd /tmp/oca-port/account-financial-tools
git log --oneline -5
git diff 19.0...19.0-oca-port-account_lock_date_update
# Common manual fixes after oca-port:
# 1. Replace deprecated api.multi decorators (removed in v14+)
# 2. Update JS imports from web.core to @web/core
# 3. Fix QWeb template inheritance (t-inherit, t-inherit-mode)
# 4. Update field definitions (removed compute_sudo in favor of sudo())
# 5. Replace old-style views with new-style XML IDs
# Run module tests locally before submitting
/opt/odoo/venv/bin/python /opt/odoo/odoo/odoo-bin \
-d test_port \
--addons-path=/opt/odoo/odoo/addons,/tmp/oca-port \
-i account_lock_date_update \
--test-enable \
--stop-after-initOption 2: Fork and Maintain Privately
Sometimes you need a module working now and the upstream PR review process takes weeks. Fork the repository, apply your fixes, and pin your fork in your deployment config. But track the upstream PR — the moment it's merged, switch back to the official source. Private forks accumulate technical debt quickly: every time the OCA merges a bug fix or security patch to the 19.0 branch, your fork falls further behind.
We maintain a strict rule for private forks: every fork gets a calendar reminder at 30, 60, and 90 days. At each checkpoint, check whether the upstream PR was merged. If it was, rebase your production deployment to the official branch. If it wasn't, escalate — either push the PR forward with additional review comments, or accept the fork as a long-term dependency and add it to your module governance document.
Option 3: Find an Alternative or Build In-House
If a module is abandoned (no commits in 18+ months, no 19.0 branch, maintainer unresponsive), it's time to look for alternatives. Check the OCA repository index first — there are often two or three modules solving the same problem, and the one you happened to install three years ago may not be the best option anymore.
Before building a replacement from scratch, do a cost-benefit analysis. A typical OCA module represents 40-200 hours of development. If your usage of the module is limited to one or two features, a targeted in-house module that covers just your use cases might take 8-20 hours. But if you're using the full feature set — including reports, wizards, and configuration screens — the replacement cost can exceed the cost of the entire migration project. In those cases, investing in the upstream port is almost always more economical.
| Decision Factor | Port Upstream | Fork Privately | Replace / Build |
|---|---|---|---|
| Module complexity | Low-Medium (< 2,000 lines) | Any size | When simpler alternatives exist |
| Timeline pressure | Can wait 2-4 weeks for review | Need it this sprint | Can wait for development |
| Long-term maintenance | Community maintains it | Your team maintains the fork | Your team or new community |
| Upstream health | Active maintainer, CI green | Slow maintainer, but code works | Abandoned / unmaintained |
3 Third-Party Module Mistakes That Derail Odoo 19 Migrations
Pinning OCA Modules to a Branch Instead of a Commit Hash
Most teams clone OCA repositories by branch: git clone --branch 19.0 OCA/account-financial-tools. The problem? Branches are moving targets. An OCA contributor merges a fix on Tuesday, and your Wednesday deployment pulls in untested code. We've seen this break production when an OCA commit introduced a new dependency that wasn't in the deployment's requirements.txt. The module installed fine in the contributor's dev environment but threw ModuleNotFoundError in production.
Pin every OCA repository to a specific commit hash in your repos.yml or gitaggregate config. When you want to update, do it deliberately: review the diff between your pinned hash and the branch HEAD, run tests, then update the pin. Treat OCA dependencies with the same rigor you'd treat PyPI packages — version-locked and tested before deployment.
Ignoring Data Migration in Third-Party Module Upgrades
Third-party modules store data — configuration records, computed fields, transient model entries, and custom sequences. When you upgrade from the 17.0 to the 19.0 version of an OCA module, the Python code updates but the database schema may not. Fields get renamed, models get merged, and stored computed fields change their calculation logic. The module installs without errors, but the data is wrong: invoices reference a field that no longer exists, stock moves use an obsolete computation, or configuration records point to deleted menu items.
Check every third-party module's migrations/ directory before upgrading. If the module jumps from version 17.0.1.2.0 to 19.0.1.0.0 and has no migration scripts, that's a red flag. Compare the model definitions between versions: diff the models/ directories and look for renamed fields, changed field types, or removed models. Write pre-migration scripts to handle data transformations that the module author didn't account for.
Testing Modules in Isolation Instead of Together
Module A works. Module B works. Install both and the system crashes. This is the module interaction problem, and it's the hardest class of bug to catch. Two OCA modules that independently override the same method on sale.order will conflict when both are installed. One module's _compute_amount_total override replaces the other's, silently dropping a discount calculation or tax adjustment. These bugs don't surface in unit tests because each module is tested against vanilla Odoo, not against the full stack of 40 modules you're running in production.
After individual module tests pass, run a full-stack integration test that installs every module simultaneously — exactly matching your production configuration. Use an anonymized production database dump for this test, not demo data. Run the test suite with --test-tags=post_install to catch interaction bugs. This is slower (2-4 hours for a large module set) but it's the only way to catch method resolution order (MRO) conflicts and template inheritance collisions.
What a Proper Third-Party Module Audit Saves Your Migration
Third-party module issues account for 60-70% of migration delays we see in the field. The audit and testing process described above typically takes 2-3 weeks for a 40-module stack. Here's the ROI:
Teams that skip the audit discover module incompatibilities mid-migration, causing rework cycles that add 3-6 weeks to the project. Front-loading the audit eliminates these surprises.
Automated regression testing with full-stack integration catches module interaction bugs before users see them. The remaining 20% are edge cases caught in UAT.
Mapping every third-party module to an OCA alternative or in-house replacement means you're never blocked by an abandoned App Store vendor refusing to port to 19.0.
Beyond timeline savings: the audit document becomes your module governance policy. It tells future developers exactly what's installed, why, and what alternatives were considered. When the next major upgrade arrives (Odoo 21, 23), you won't be starting from scratch — you'll be updating a living document.
One often-overlooked benefit: the audit process frequently identifies modules that are installed but unused. We routinely find 3-5 modules per client that were installed for a POC, enabled for a feature that was never adopted, or replaced by a different module without being uninstalled. Removing these reduces the attack surface, speeds up the upgrade, and simplifies future maintenance.
Optimization Metadata
Audit, test, and upgrade third-party OCA modules for Odoo 19. Compatibility matrix, automated regression testing, dependency resolution, and migration scripts.
1. "Building a Complete Third-Party Module Inventory for Odoo 19 Migration"
2. "Automated Regression Testing for Third-Party Modules in Odoo 19"
3. "3 Third-Party Module Mistakes That Derail Odoo 19 Migrations"