$Why XML-RPC fails as a migration tool
Odoo's documented "external API" is XML-RPC (or its JSON-RPC variant). The pattern most engineers reach for first is: spin up the production database, walk staging models in dependency order (companies → currencies → users → partners → products → listings...), and for each record, call execute_kw("create", ...) against production.
This approach breaks in four expected ways and one unexpected one:
- ID assignment is non-deterministic. Production assigns its own primary keys. A product that was ID 4,237 on staging becomes ID 891 in production. Every foreign-key reference (sales orders, stock moves, accounting entries that point at the product) needs translation through an ID-mapping table you maintain in memory during replay. Get one wrong → silently broken references.
ir_model_dataXML-IDs don't transfer. Module-defined records (default tax codes, system permissions, demo data) have stable XML-IDs likeaccount.account_tax_default_sale. These IDs map to different database IDs in staging and production. If your replay tries to copy these via XML-RPC, you create duplicates or write conflicting records.- Attachments are slow and lossy. Each binary field (product images, PDFs, signed contracts) gets transmitted as base64 over XML-RPC. A 50,000-image catalog at ~500KB each is 25GB of payload over a protocol designed for small operations. Connection timeouts kill the run mid-transfer; resuming requires custom state-tracking.
- Computed and stored fields fire on every
create(). An XML-RPC replay triggers Odoo's full ORM compute chain for each record. Image derivative generation, tax recomputation, BoM cost rollups: these compound. A 10,000-product replay that should take 10 minutes takes 6 hours. - And the unexpected one:
create()doesn't accept all the fields you read. Many fields are computed and not writable. Some are inverse-functional (you set them via a different field). XML-RPC raises mysterious "field not in model" errors halfway through, leaving partial state.
The XML-RPC approach is the right tool for ongoing integration (a sync between Odoo and an external CRM, say), where you want Odoo to compute and validate as records flow in. It's the wrong tool for migration, where you want byte-exact preservation of what you've already validated in staging.
$The pattern: selective PostgreSQL table copies
The pattern is: dump the relevant subset of tables from staging at the PostgreSQL level, restore into a freshly-installed production instance, then fire only the post-load compute steps that matter.
"Selective" because copying the whole database is also wrong; it brings staging's ir_logging, mail_message history, queued jobs that already ran, and other run-state that you don't want polluting production. The subset is data tables only.
Six-step plan:
- Pin module versions on production to match staging exactly.
- Run
-u allon the fresh production instance to populateir_model_dataand base data. - Snapshot the production
ir_model_datatable to a backup before any data load. - Dump the data tables from staging with
pg_dump --data-only --table=...for each. - Restore into production, deferring constraint checks until end-of-load.
- Replay only the compute steps that matter (image derivatives, search indexes, tax recomputes) via specific RPC calls.
$Step 1: pin module versions
Production must run identical module versions to staging. Same Odoo core version (18.0.x patch level), same OCA modules at the same git SHAs, same custom modules at the same git SHAs. Any version drift between staging and production introduces schema differences that pg_restore will not handle. A field renamed in 18.0.2 vs 18.0.1 means the dump's column doesn't exist on the target.
Use a pinned requirements.txt and a pinned addons-folder git submodule SHA list. The deploy command:
# From a clean production filesystem.
git checkout $STAGING_SHA
git submodule update --init --recursive
pip install -r requirements.txt --no-deps
odoo --stop-after-init -i base,web,mail --without-demo=all -d odoo_prod
The --without-demo=all is critical. Staging probably has demo data; you don't want it in production.
$Step 2: install all modules on fresh production
Once base is installed, run -u all for every module that staging has:
odoo --stop-after-init -i product,sale,stock,account,marketplace_base,fl_marketplaces -d odoo_prod
This populates production's ir_model_data, ir_model, ir_model_fields, ir_ui_view, ir_actions_*, default config records, and base data. Production now has the full schema and all module-defined fixtures.
Verify both databases have identical module sets:
psql odoo_staging -c "SELECT name, latest_version FROM ir_module_module WHERE state='installed' ORDER BY name" > /tmp/staging.modules
psql odoo_prod -c "SELECT name, latest_version FROM ir_module_module WHERE state='installed' ORDER BY name" > /tmp/prod.modules
diff /tmp/staging.modules /tmp/prod.modules
Any diff → resolve before proceeding. Different module versions or different module sets will break the data copy in non-obvious ways.
$Step 3: back up production's fresh ir_model_data
This is the step that prevents the silent-corruption mode. Before you load staging data, capture production's freshly-populated ir_model_data:
pg_dump odoo_prod --data-only --table=ir_model_data > /tmp/prod_ir_model_data_fresh.sql
You will need to merge this with staging's ir_model_data later, preferring production's IDs for module-defined records. More on that in Step 5.
$Step 4: dump staging's data tables
The data subset for an e-commerce migration is typically:
# Master data tables. Adjust for your modules.
TABLES=(
res_partner res_users res_company
product_category product_template product_product
product_pricelist product_pricelist_item
stock_warehouse stock_location stock_quant
account_account account_tax account_journal
marketplace_listing marketplace_credentials
fl_marketplaces_amazon_listing fl_marketplaces_ebay_listing
ir_attachment
ir_model_data
)
for T in "${TABLES[@]}"; do
pg_dump odoo_staging --data-only --table="$T" -f /tmp/staging_$T.sql
done
What you explicitly do not dump:
ir_logging: staging log noisemail_message,mail_followers: staging chatter; restart fresh in productionqueue_job,queue_job_log: already-run staging jobsir_cron_*run historysale_order,stock_picking,account_move: transactional data is a separate, careful copy that we'll address below
$Step 5: restore with constraint-deferral
The pg_dumps go in via psql, but with foreign-key constraints deferred until the end:
psql odoo_prod << 'EOF'
SET CONSTRAINTS ALL DEFERRED;
TRUNCATE ir_attachment CASCADE; -- empty fresh prod
EOF
for T in "${TABLES[@]}"; do
psql odoo_prod -1 -f /tmp/staging_$T.sql # -1 = single transaction per file
done
For ir_model_data specifically, you need the merge. Staging's module-defined entries overwrite production's, but production's auto-generated entries that aren't in staging are preserved. The simplest pattern is to truncate ir_model_data before the restore (since production was freshly installed and staging's ir_model_data is the authoritative one for the merged state).
After all tables loaded, reset PostgreSQL sequences so production's auto-incrementing IDs don't collide with the staging-imported max IDs:
SELECT setval(pg_get_serial_sequence('product_template', 'id'),
(SELECT MAX(id) FROM product_template) + 1);
-- Repeat for every table with a serial PK
The pattern: for each table you copied, advance its sequence past the max imported ID. There's an Odoo helper at odoo.tools.misc.reset_sequence(env, model) but raw SQL is faster for bulk.
$Step 6: replay specific compute steps
You loaded byte-exact data. The records didn't go through Odoo's ORM, so no compute methods fired. For most fields, that's correct; the staging values are already correct. For a few, you need to recompute on production:
- Image derivatives. If your
ir_attachmentcopy brought masters but Odoo generates the small/medium/large sizes lazily, force them viaproduct.product._compute_image_*on every record. See the derivative-cascade post for the bulk pattern that doesn't take 11 minutes per thousand. - Search indexes. If you use Odoo's full-text search, run
VACUUM ANALYZEon PostgreSQL and force a search-vector recompute on indexed models. - Stored computed fields. If you renamed a field or changed a compute logic between staging deploys, run
env[model].recompute()explicitly for the affected fields.
These are the only compute steps worth firing post-load. Everything else stays as it was on staging.
$What about transactional data?
The plan above is for configuration/master data. If you're moving open sales orders, in-progress stock pickings, or unbilled invoices from staging to production, the rule is: don't. Cut over on a known clean boundary: all open orders closed or marked won't-migrate, all stock pickings either complete or cancelled. Production starts with zero in-flight transactions.
If business reality requires you to migrate in-flight transactions (rare, but happens during phased rollouts), copy them after Step 5 in dependency order: sale_order → sale_order_line → stock_picking → stock_move → stock_move_line → account_move → account_move_line. Sequence resets apply to each. Test against staging-replica-of-production first.
$The diagnostic that catches missed dependencies
After Step 5, before opening production to users, run:
def verify_referential_integrity(env):
"""Walk every Many2one field; verify all targets exist."""
broken = []
for model_name in env.registry:
model = env[model_name]
for field_name, field in model._fields.items():
if field.type != "many2one":
continue
target_model = env[field.comodel_name]
records = model.search([])
for r in records:
ref = r[field_name]
if ref and not ref.exists():
broken.append((model_name, r.id, field_name, ref.id))
return broken
Run this in an Odoo shell against production after load. Empty list → referential integrity is intact. Any entries → you missed a table in the dump, or the constraint-deferral didn't catch a circular reference.
On the 47,000-record migration this practice originated from, the first dry-run had 12 broken references, all from res_partner.country_id pointing at country IDs that existed in staging but not in the fresh production install. Adding res_country and res_country_state to the dumped tables fixed it.
$Why not just pg_dump the whole database?
Two reasons not to:
- You bring staging cruft. Log tables, mail history, queue-job state. Production starts with a perfect record of staging's mistakes.
- You bypass module-state validation. Selective restore +
-u allon the target validates that your module set installs cleanly against an empty database. Full database restore could hide a broken module dependency that will detonate the next time someone runs-u.
The selective pattern is more work up front but produces a production database that's clean from day one. The XML-RPC approach is the same work spread over a longer window and gives you record-by-record fragility.