Skip to content

About

Migrate an Odoo 18 Enterprise SaaS database dump into a self-hosted Community instance. Fresh-init plus business-data injection, with automatic Enterprise column filtering. MIT.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

6 Commits

Folders and files

Repository files navigation

odoo18-ee2ce — Odoo 18 Enterprise → Community migration

Import an Odoo 18 Enterprise SaaS database dump into a self-hosted Community instance, the way you wished pg_restore could.

License: LGPL v3 Python: 3.9+ Odoo: 18.0

Why this exists

Odoo Enterprise SaaS exports are full pg_dump SQL files, but they can't be loaded directly into a Community instance. Enterprise and Community have deeply incompatible metadata: different module registries, different view definitions, different action paths, hundreds of Enterprise-only columns, and entire tables that don't exist on the Community side. Attempting to import the full dump leads to an endless chain of conflicts.

This tool sidesteps the problem with a fresh-init + business-data injection strategy:

  1. Drop and re-create the target database with a freshly initialized Community Odoo instance (correct module set, correct metadata)
  2. Parse the Enterprise dump.sql for COPY data blocks
  3. Import only business data (journal entries, partners, tasks, mail messages, etc.) with automatic column filtering so Enterprise-only columns are silently dropped
  4. Skip every framework / metadata table — Community already has the right ones

The result is a Community database with all your business data, ready to use, no -u base patching required.

How it works — the nine phases

Phase What it does
0 Drops the target DB, runs odoo -d <db> -i <modules> to create a fresh Community DB with correct metadata
1 Single-pass parses the Enterprise dump.sql for COPY ... FROM stdin blocks
2 Connects to the freshly-init'd target DB, catalogs all tables
3 Plans the import: which tables to import, which to skip (framework / Enterprise-only)
4 Streams each COPY block through psycopg2 with column intersection filtering. session_replication_role = 'replica' disables FK triggers during import; NOT_NULL_FIXUPS relaxes Community-only mandatory columns
5 Resets every sequence to max(linked_column) to avoid PK collisions on the next insert
6 Copies the extracted Enterprise filestore into the Odoo container
7 Neutralizes the new DB: disables outbound mail, regenerates database.uuid, sets a safe web.base.url, ensures mail_alias_domain has at least one row
8 Prints row counts for verification
9 Writes the Enterprise-only rows it could not place to enterprise-leftovers.json

What gets imported vs skipped

Imported (~140 tables): account_move, account_move_line, account_account, account_journal, res_partner, crm_lead, project_project, project_task, sale_order, account_analytic_line, mail_message, blog_post, ir_attachment, and all related junction/child tables.

Skipped (~800 tables):

  • Framework metadata: ir_model_data, ir_ui_view, ir_act_window, ir_module_module, etc. — managed by Community module installation.
  • Reference data: res_country, res_currency, uom_uom — already seeded by base module CSVs.
  • Enterprise-only: documents_*, sign_*, planning_*, knowledge_*, sale_subscription_*, studio_*, etc. — no Community equivalent.
  • Security groups: res_groups, res_groups_users_rel — managed by module install.

See odoo18_ee2ce/config/skip_tables.py for the full list.

Requirements

  • Python 3.9+ (psycopg2-binary is the only runtime dep)
  • Docker CLI (containers must be running)
  • An Odoo 18 Community Docker container (DB + Odoo)
  • An extracted Odoo 18 Enterprise SaaS dump (manifest.json + dump.sql + filestore/)

Install

# Via uv (recommended)
uv tool install git+https://github.com/Symbifox/odoo18-ee2ce

# Or pipx
pipx install git+https://github.com/Symbifox/odoo18-ee2ce

# Or local checkout
git clone https://github.com/Symbifox/odoo18-ee2ce
cd odoo18-ee2ce
pip install -e .

Quick start

# 1. Extract the Enterprise SaaS dump
mkdir -p /tmp/enterprise-dump
cd /tmp/enterprise-dump
unzip /path/to/enterprise-backup.zip
# → manifest.json, dump.sql, filestore/<dbname>/

# 2. Run the migration
DB_PASSWORD=mypassword odoo18-ee2ce \
    --dump /tmp/enterprise-dump/dump.sql \
    --filestore /tmp/enterprise-dump/filestore/<dbname> \
    --target-db my-community-db \
    --db-container my-postgres-container \
    --odoo-container my-odoo-container \
    --db-user odoo

# 3. After completion, widen dbfilter and access the new DB:
#    Edit odoo.conf:    dbfilter = ^my-community-db$
#    Restart container: docker restart my-odoo-container
#    Visit:             http://localhost:8069/web

CLI reference

odoo18-ee2ce \
    --dump PATH                      Path to Enterprise dump.sql (REQUIRED)
    --target-db NAME                 Target DB name (will be DROPPED if --skip-init not set) (REQUIRED)
    --db-container NAME              PostgreSQL Docker container name (REQUIRED)
    --odoo-container NAME            Odoo Docker container name (REQUIRED)
    --db-user NAME                   DB user / role (REQUIRED)

    --filestore PATH                 Filestore dir (default: <dump-dir>/filestore)
    --modules LIST                   Modules to install during init (default: see config/default_modules.py)
    --db-host HOST                   DB host (auto-detected from Docker if omitted)
    --db-port PORT                   DB port (default: 5432)
    --db-password PASS               DB password (prefer env vars or .env file)

    --skip-init                      Don't drop & re-init target DB (assume already done)
    --skip-filestore                 Don't copy filestore
    --dry-run                        Parse and plan only, no DB changes

    --skip-tables-extra PATH         Text file with additional tables to skip (one per line)
    --not-null-fixups PATH           JSON file with extra NOT NULL relaxation rules (merged with defaults)
    --verify-counts PATH             JSON file mapping table → expected_min_count for verification
    --neutralize-base-url URL        web.base.url to set during neutralization (default: localhost:8069)

Password resolution order

  1. --db-password CLI argument
  2. PGPASSWORD environment variable
  3. DB_PASSWORD environment variable
  4. DB_PASSWORD= or POSTGRES_PASSWORD= in .env file (cwd or parent)

What is left behind

Phase 9 writes every Enterprise-only table it could not place into enterprise-leftovers.json, next to the dump. The file is small — these tables are narrow and short compared to the business data — and it is plain JSON:

{
  "format": "odoo18-ee2ce/leftovers",
  "format_version": 1,
  "source": {"dump": "dump.sql", "db_name": "...", "version": "saas~18.3"},
  "tables": {
    "helpdesk_ticket": {
      "columns": ["id", "name", "description", "..."],
      "row_count": 22, "truncated": false,
      "rows": [[1, "Printer down", "..."]]
    }
  }
}

A table lands here when it is Enterprise business data with no Community home: matched by an Enterprise module prefix, absent from the freshly initialized target, or on the collision list. Framework tables, reference data and OCR scratch are not exported — losing them costs nothing.

--no-leftovers turns it off, --leftovers PATH moves it, --max-leftover-rows caps each table (truncation is flagged per table in the file, not silent).

The companion Odoo module bf_oe2oc reads this file inside the migrated instance and re-homes what has a credible target — helpdesk tickets to OCA helpdesk_mgmt, Knowledge articles to OCA document_page — keeping the rest browsable rather than lost.

Adapting for your instance

Custom modules. Override --modules with a comma-separated list. The default covers base, account, contacts, crm, project, sale, sale_management, hr, hr_timesheet, website, website_blog, mass_mailing, calendar, helpdesk_mgmt, survey, product. If your install needs stock, mrp, purchase, etc., add them.

Extra skip rules. Pass --skip-tables-extra skip.txt with one table name per line:

my_custom_table
my_custom_module_helper

Extra NOT NULL relaxations. If you hit a NOT NULL violation on import, add an entry via --not-null-fixups fixups.json:

{
  "my_table": {
    "my_required_col": ["'default_value'", null]
  }
}

Format is [default_value_sql, optional_custom_update_sql]. If the second element is null, the importer runs UPDATE my_table SET my_required_col = 'default_value' WHERE my_required_col IS NULL.

Verify counts. To compare row counts against expected minimums:

{
  "res_partner": 100,
  "account_move": 500
}

Validation status

Read this before you point it at a database you care about.

The pipeline runs end to end against an Enterprise-shaped dump. Phases 0 through 8, against a purpose-built fixture: 62 COPY blocks, 18,865 rows, 28 tables shared with Community, 18 Enterprise-only tables, 16 framework tables that have to be skipped, and 40 Enterprise-only columns scattered through the shared tables' column lists. Last run: 28 tables imported, 17,950 rows, 0 errors, every table matching its expected row count, and the resulting database read back through the Odoo ORM afterwards. Phase 9 wrote 915 rows across 18 Enterprise-only tables to the leftovers file, which the companion module then loaded and re-homed. Repeated on four different --seed values, because a fixture that only passes on one seed is a fixture that is passing on luck.

What the fixture is, exactly. tests/make_ee_fixture.py builds it against a live, freshly initialized Community database. The table names are transcribed from a measured inventory of a real Odoo 18 Enterprise SaaS database — saas~18.3.1.3, 939 tables, 293 installed modules of which 135 were Enterprise-only — and the row counts are that inventory rounded to orders of magnitude, which keeps the proportions realistic without publishing anyone's activity profile. The column names marked x_ee_* are synthetic; the others are Enterprise field names, and the generator does not take them on trust: each one is checked against the live Community schema and discarded if it already exists there. What survives is, by construction, a column Community does not have — which is the only property the intersection step depends on. Row values are synthetic but type-correct against the live schema (jsonb translations, timestamps, numerics, bytea), written in PostgreSQL text-COPY format with real escaping.

What that does not prove. The fixture is not a real Enterprise dump. It does not carry Odoo's actual Enterprise column layouts, the real volumes (193 MB of SQL, 2,032 filestore files), or the accidents a long-lived production database accumulates. Treat the first run against your own dump as an experiment: work on a copy, read Phase 4's error list rather than the summary line, and expect to add entries to not_null_fixups.py and skip_tables.py.

Known not to be covered. Enterprise helpdesk → OCA helpdesk_mgmt mapping (dropped on purpose, see Limitations). Studio fields. Multi-company dumps — the reference instance was single-company, so the company-scoped paths are untested.

Running the validation yourself

# 1. A throwaway Community target (the compose file ships with this repo)
docker compose -f examples/docker-compose.yml up -d
docker exec ee2ce-odoo odoo -d ee2ce_target \
    -i base,account,contacts,crm,project,sale,sale_management,hr,\
hr_timesheet,website,website_blog,mass_mailing,calendar,survey,product \
    --stop-after-init --no-http --without-demo=all

# 2. Generate the Enterprise-shaped dump from that live schema
python tests/make_ee_fixture.py --db ee2ce_target --user odoo \
    --password odoo --out /tmp/ee-fixture \
    --host "$(docker inspect ee2ce-db \
        --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')"

# 3. Run the migration against it
odoo18-ee2ce --dump /tmp/ee-fixture/dump.sql --target-db ee2ce_target \
    --db-container ee2ce-db --odoo-container ee2ce-odoo --db-user odoo \
    --db-password odoo --modules base,account,contacts,crm,project,sale,\
sale_management,hr,hr_timesheet,website,website_blog,mass_mailing,calendar,\
survey,product

# 4. Tear it down
docker compose -f examples/docker-compose.yml down -v

Pass --verify-counts a JSON file of {table: expected_rows} — the fixture's own SHARED dict — to make Phase 8 assert rather than just report. Vary --seed to confirm the result is not seed-dependent.

The unit tests need no database: pytest tests/.

Limitations

  • Odoo 18 only. The skip lists and NOT NULL fixups are version-specific. Older versions need different rules.
  • No Studio support. x_studio_* fields are dropped on import. If your Enterprise instance uses Studio extensively, port custom fields to a Community module before migration — Symbifox Forge does this without writing one, though it is proprietary rather than free software.
  • One-shot import. Creates a new database; doesn't merge into an existing one.
  • Filestore assumes Docker volumes. The docker cp approach requires the Odoo container to be running.
  • Enterprise-only data is set aside, not migrated. Modules without a Community or OCA equivalent (Knowledge articles, Sign requests, Planning shifts, Marketing automation flows, Subscriptions) have no table to land in. Since 0.4.0 their rows are written to enterprise-leftovers.json rather than dropped — see What is left behind below — but placing them on Community models is another tool's job.
  • OCA helpdesk swap. Enterprise helpdesk data is not auto-mapped to OCA helpdesk_mgmt; the v1 attempt at this proved too brittle. Note that helpdesk_mgmt names its table helpdesk_ticket, exactly like Enterprise: before 0.4.0 the planner saw the name in the target and imported into it, DELETE FROM included. That name is now on an explicit collision list and goes to the leftovers file instead.

Lessons learned

These hard-won insights drove the design — see docs/ARCHITECTURE.md for the full story.

  • Never import framework metadata (ir_model_data, ir_ui_view, ir_act_window, etc.) from Enterprise into Community. The schemas differ massively.
  • Never import reference data (res_country, res_currency, uom_uom). The fresh DB already has them; overwriting breaks ir_model_data xml_id references.
  • Never use TRUNCATE ... CASCADE on an Odoo database. It cascades through all FK chains and can wipe the entire DB.
  • session_replication_role = 'replica' disables FK triggers but NOT NOT NULL or CHECK constraints — that's why NOT_NULL_FIXUPS exists.
  • Reset with 'origin' (not 'DEFAULT' — that's invalid in PostgreSQL).
  • Fresh DB init creates correct metadata. All Community module views, actions, menus, security rules are created by odoo -i. No need to merge Enterprise metadata.

Contributing

Bug reports and PRs welcome. The most useful contributions are:

  • New entries in config/not_null_fixups.py for tables you hit
  • New entries in config/skip_tables.py for Enterprise modules added in newer Odoo 18 builds
  • Cases for tests/make_ee_fixture.py — a table or column shape that broke on your dump is worth more than a bug report
  • Parser edge cases in tests/fixtures/, for dump syntax the single-pass reader mishandles

License

Copyright (c) 2026 Les services de consultation Blue Fox, Inc.

This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See LICENSE for the LGPL-3 terms and LICENSE.GPL-3 for the GPL-3 text it incorporates, or https://www.gnu.org/licenses/.

It is not an Odoo module and imports nothing from Odoo, so no licence is inherited; copyleft here is a choice. What is worth anything in this tool is its skip lists and its NOT NULL fixups, which grow one migration at a time, and that is the part worth getting back. The lesser variant leaves you free to import odoo18_ee2ce as a library inside your own migration tooling.

Built and used in production by Symbifox (Les services de consultation Blue Fox, Inc.) to migrate the company's own SaaS instance to self-hosted Community in February 2026.

About

Migrate an Odoo 18 Enterprise SaaS database dump into a self-hosted Community instance. Fresh-init plus business-data injection, with automatic Enterprise column filtering. MIT.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages