[FROSTLABS] · home / writing / odoo staging→prod
2026-05-10 · 8-min read · Odoo · Database migration · DevOps

Migrating Odoo staging to production without XML-RPC.

XML-RPC is the documented Odoo external API. It's also the wrong tool for moving a complete configuration (customer master, product master, marketplace listings, custom fields, attachments) from staging to production. Record-by-record API replay loses IDs, breaks ir_model_data references, drops attachments, and takes hours per thousand records. The right approach is selective PostgreSQL table copies with module-version-pinning. Here's the migration plan that's worked on six engagements, including one with 47,000+ records.

$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:

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:

  1. Pin module versions on production to match staging exactly.
  2. Run -u all on the fresh production instance to populate ir_model_data and base data.
  3. Snapshot the production ir_model_data table to a backup before any data load.
  4. Dump the data tables from staging with pg_dump --data-only --table=... for each.
  5. Restore into production, deferring constraint checks until end-of-load.
  6. 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:

$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:

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:

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.

By David H. Frost · Frost Labs LLC More writing · Home · Privacy