Import an Odoo 18 Enterprise SaaS database dump into a self-hosted Community instance, the way you wished pg_restore could.
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:
- Drop and re-create the target database with a freshly initialized Community Odoo instance (correct module set, correct metadata)
- Parse the Enterprise
dump.sqlforCOPYdata blocks - Import only business data (journal entries, partners, tasks, mail messages, etc.) with automatic column filtering so Enterprise-only columns are silently dropped
- 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.
| 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 |
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.
- Python 3.9+ (
psycopg2-binaryis 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/)
# 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 .# 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/webodoo18-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)
--db-passwordCLI argumentPGPASSWORDenvironment variableDB_PASSWORDenvironment variableDB_PASSWORD=orPOSTGRES_PASSWORD=in.envfile (cwd or parent)
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.
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
}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.
# 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 -vPass --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/.
- 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 cpapproach 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.jsonrather than dropped — see What is left behind below — but placing them on Community models is another tool's job. - OCA helpdesk swap. Enterprise
helpdeskdata is not auto-mapped to OCAhelpdesk_mgmt; the v1 attempt at this proved too brittle. Note thathelpdesk_mgmtnames its tablehelpdesk_ticket, exactly like Enterprise: before 0.4.0 the planner saw the name in the target and imported into it,DELETE FROMincluded. That name is now on an explicit collision list and goes to the leftovers file instead.
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 breaksir_model_dataxml_id references. - Never use
TRUNCATE ... CASCADEon an Odoo database. It cascades through all FK chains and can wipe the entire DB. session_replication_role = 'replica'disables FK triggers but NOTNOT NULLorCHECKconstraints — that's whyNOT_NULL_FIXUPSexists.- 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.
Bug reports and PRs welcome. The most useful contributions are:
- New entries in
config/not_null_fixups.pyfor tables you hit - New entries in
config/skip_tables.pyfor 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
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.