An Odoo data migration looks deceptively simple: "export your data from the old system, import it into Odoo." The reality is that source data is almost always inconsistent, mappings are almost always non-trivial, and the order of operations determines whether the migration succeeds or fails silently. The hardest migration problems aren't the rows that fail to import (those get caught) — they're the rows that import successfully into the wrong place.
This guide is for developers and technical leads planning or executing an Odoo migration from a legacy ERP, a custom database, an e-commerce platform, or another business system. It covers strategy, the import mechanisms Odoo provides, validation patterns, and the cutover process — including the operational decisions that determine whether the migration is recoverable if something goes wrong.
Migration is an ETL project, not a data dump
The standard pattern for any Odoo migration:
- Extract from the source system into structured intermediate files (CSV, JSON, or a staging database).
- Transform the data to Odoo's expected shapes — field renaming, value normalization, reference resolution, calculated fields.
- Validate the transformed data against Odoo's constraints — required fields, valid foreign keys, value ranges.
- Load into Odoo via XML-RPC, JSON-RPC, the import wizard, or direct ORM calls in a custom module.
The Validate step is the one that separates migrations that ship successfully from ones that produce data quality problems for years. Source data has typos, dropped fields, mis-coded categories, mixed currencies, and date formats that look right but parse wrong. Validation catches all of these before they reach Odoo.
The migration order that always works
Migrate in dependency order. A record cannot reference data that doesn't exist yet.
- Configuration data: countries, states, currencies, languages — usually loaded by Odoo's localization, but verify before going further.
- Reference data: product categories, attribute definitions, account chart customizations, tax codes.
- Master data — partners: customers, suppliers, contact persons. Includes addresses, payment terms, default categories.
- Master data — products: templates first, then variants. Includes UoM, supplier links, accounting accounts per category.
- Master data — bills of materials (if manufacturing): components, routings, work centers.
- Open transactions: open sale orders, open purchase orders, open invoices, open shipments.
- Inventory: opening stock per product per location.
- Accounting balances: opening account balances as of cutover date.
Skip this order — try to import an open invoice before the customer and product exist — and the import fails on every row. Following the order produces clean imports.
External IDs are non-negotiable
Every record imported into Odoo should have an external ID (also called XML ID). The external ID is a stable reference that survives between runs. It's how you detect duplicates and update existing records on re-import.
Odoo's import format expects the external ID in a column named id or External ID. The convention: module_name.record_identifier.
# Example: products with external IDs
id,default_code,name,list_price,categ_id
__migration__.product_SKU001,SKU001,Red T-Shirt,29.99,__migration__.cat_apparel
__migration__.product_SKU002,SKU002,Blue T-Shirt,29.99,__migration__.cat_apparel
__migration__.product_SKU003,SKU003,Leather Belt,49.99,__migration__.cat_accessoriesThe __migration__ module prefix is a convention — choose anything stable. The benefit appears on re-import: Odoo finds the existing record by external ID and updates it rather than creating a duplicate. This is what makes migrations re-runnable.
__migration__.cat_apparel — Odoo resolves the category by external ID. This means categories must be imported before products that reference them.Customer/partner migration
Partners (res.partner) are the foundation. Every order, invoice, and contact references a partner. Get partners right; everything downstream gets easier.
Required fields
name— display name (company or person name).company_type— person or company.is_company— boolean, true for companies.
Address fields
street,street2,city,state_id,zip,country_id.- State and country are foreign keys; reference by external ID (e.g.,
base.state_us_5for California).
Commercial fields
customer_rank— >0 means customer.supplier_rank— >0 means supplier.vat— VAT/tax ID. Odoo validates the format per country.property_payment_term_id— payment terms (foreign key).property_account_receivable_id/property_account_payable_id— accounting accounts.
Contact persons attached to companies
Use parent_id to attach a contact to its company. Both must exist; Odoo creates a hierarchy where the contact inherits company-level fields like address.
# Companies first
id,name,is_company,company_type,vat,country_id
__migration__.partner_acme,Acme Corp,True,company,US123456789,base.us
# Then contacts within companies
id,name,is_company,parent_id,email,function
__migration__.contact_jane_acme,Jane Doe,False,__migration__.partner_acme,[email protected],Procurement Lead Product migration
Products are the second master data set. The complication: Odoo distinguishes templates from variants.
Templates and variants
product.template— the parent record ("T-Shirt"). Holds shared attributes.product.product— the variant ("T-Shirt / Red / Medium"). Auto-generated when attributes are configured.
For products without variants, importing the template creates one auto-generated variant. For products with variants, the order is: template → attributes → values → variants attach automatically based on attribute combinations.
Categories and accounting
Categories carry accounting policy: income account, expense account, valuation account, valuation method. Configure categories in Odoo before importing products; the products inherit category defaults.
Suppliers per product
Supplier-product links live on a separate model: product.supplierinfo. Each link records: which supplier, supplier's product code, lead time, supplier price, validity dates. Import these after products and partners exist.
Open transactions: orders, invoices, shipments
Open transactions — sales not yet shipped, purchase orders not yet received, invoices not yet paid — are migrated as open records, not historical ones. The destination state matters.
Open sale orders
Import as quotations (state draft) or sales orders (state sale) depending on whether they've been confirmed in the legacy system. Required fields: partner, lines (with products and quantities), prices, taxes.
# Sale order header
id,name,partner_id,date_order,state
__migration__.so_001,SO/2026/0001,__migration__.partner_acme,2026-04-15,sale
# Sale order lines (one row per line)
id,order_id,product_id,product_uom_qty,price_unit
__migration__.so_001_l1,__migration__.so_001,__migration__.product_SKU001,5,29.99
__migration__.so_001_l2,__migration__.so_001,__migration__.product_SKU003,2,49.99Open invoices
Account moves (invoices) require the chart of accounts to be set up. Import unpaid invoices with state posted and an open balance. Payment registration happens in Odoo when the customer pays — even if the legacy system tracked partial payments, simplify by booking the residual amount as the open invoice and writing off the paid amount as opening balance.
Open shipments
If products have been picked but not shipped, model as a stock picking in assigned state. The picking will be validated when the actual shipment leaves on or after cutover. Don't try to migrate picking history before the cutover date — that's archival data.
Inventory migration
Inventory at cutover is migrated via physical inventory adjustments — Odoo's mechanism for declaring "as of this date, this product at this location has this quantity."
The cleanest approach
- Reset inventory in Odoo to zero (zero per product per location).
- Import the cutover snapshot as a single physical inventory.
- Validate the inventory; Odoo creates the stock moves automatically and posts to the inventory adjustment account.
Required fields per inventory line: product (external ID), location (external ID), quantity. Lots and serial numbers attach if the product is tracked.
id,location_id,product_id,inventory_quantity,reserved_quantity
__migration__.inv_SKU001_main,stock.stock_location_stock,__migration__.product_SKU001,150,0
__migration__.inv_SKU002_main,stock.stock_location_stock,__migration__.product_SKU002,80,0
__migration__.inv_SKU003_main,stock.stock_location_stock,__migration__.product_SKU003,42,0Accounting balances
Opening balances are migrated as journal entries dated the day before go-live. Each account that has a balance gets an entry; the offsetting account is the Opening Balance account (or equity account, depending on local accounting practice).
The structure
Create one journal entry per account, dated cutover_date - 1:
- Debit: account with debit balance.
- Credit: corresponding equity/opening balance account.
- Reverse for accounts with credit balance.
Once all opening balance entries are posted, the trial balance in Odoo on cutover date should match the trial balance in the legacy system. This match is the proof that accounting migration is correct. If the totals don't match, do not go live — the discrepancy is unrecoverable later.
Open AR/AP
Open invoices migrated in the previous step contribute to AR/AP balances. Make sure the opening balance entries don't double-count: the AR balance comes from open invoices, not from a manual opening balance for the AR account.
Cutover strategy
The cutover is the moment the legacy system stops being authoritative and Odoo takes over. Two patterns:
Pattern 1: Big bang
One sharp cutover. Legacy system goes read-only at time T; Odoo goes live at time T+ε. All migration happens in the gap.
Strengths: Clean break. No dual-system operation.
Risks: If migration fails, rollback is messy. Requires a tight, well-rehearsed cutover plan.
Right for: Most retailers under 200 employees. The simplicity wins.
Pattern 2: Parallel run
Both systems operate for a period (typically 1–4 weeks). All transactions are entered in both. Reconciliation runs daily.
Strengths: Catches data issues with low risk. Operators stay productive even if Odoo has problems.
Risks: Double work for the team. Reconciliation overhead.
Right for: Larger operations or complex businesses where the cost of a failed cutover is greater than the cost of dual operation.
The cutover checklist (big-bang)
- Migration scripts pass validation against latest production data extract (run weekly, then daily, then twice on cutover day).
- Cutover day: legacy system goes read-only at T-0.
- Final extract from legacy system at T+0:00.
- Migration scripts run from T+0:00 to T+~3 hours (depending on volume).
- Validation: trial balance match, open transaction count match, inventory snapshot match.
- Sign-off from finance lead and operations lead.
- Odoo goes live at T+~4 hours.
- Legacy system stays available read-only for 90 days as a safety net.
Common migration failures
1. Trying to migrate full transaction history
Legacy data goes back 10 years; the migration tries to recreate every transaction. The result: months of work, brittle data quality, and exotic edge cases that were valid in the legacy system but invalid in Odoo. Migrate forward-going state, not history. Archive history in the legacy system or a data warehouse.
2. Skipping validation
Imports run "successfully" because Odoo accepts the rows — but the rows have wrong data. Wrong tax codes, wrong currencies, wrong customer attributions. Validate every field against expectations before importing.
3. Non-idempotent imports
Re-running an import creates duplicates because external IDs aren't used. By cutover day, the database has 3x duplicate customers. External IDs on every import, every time.
4. Trial balance not reconciled
Going live without confirming the trial balance matches. Six months later, the accountant discovers €100,000 of opening differences and there is no clean way to find the source. Trial balance reconciliation is a go/no-go gate.
5. Cutover with no rollback plan
The plan assumes migration will succeed. When something fails at hour 6, there's no clear rollback. The rollback plan is part of the cutover plan.
Reference notes
Sources verified against Odoo 19.0 documentation and standards bodies. Use these to confirm anything before applying it to your environment.
- Export and import data — Odoo 19.0 documentation — Odoo 19 import/export reference — External ID/XML ID conventions, CSV and XLSX
- External JSON-2 API — Odoo 19.0 documentation — API for scripted bulk imports
- Odoo ORM reference — Odoo 19.0 documentation — create/write/unlink semantics, computed fields
- Odoo Accounting — Odoo 19.0 documentation — opening balances and chart of accounts
Frequently Asked Questions
How long does an Odoo migration take?
For a typical retail or e-commerce migration: 4–12 weeks of preparation, 1 day for cutover, 2–4 weeks of post-cutover stabilization. Preparation time is dominated by data quality work — cleansing the legacy data, mapping fields, building and testing the migration scripts. The cutover itself is the smallest part of the project.
Should I use Odoo's import wizard or write custom migration scripts?
The import wizard is fine for one-time master data loads of moderate complexity. For migrations involving multiple related entities, validation logic, or hundreds of thousands of rows, custom scripts (Python with the Odoo XML-RPC client, or a one-shot Odoo module that runs in a server cron) give you more control. The break-even is roughly when validation logic becomes hard to express in CSV transformations.
Can I migrate from Odoo Community to Odoo Enterprise?
Yes — it's the same database. Odoo Enterprise is Community plus additional modules. Migration is more an installation activity than a data migration: install Enterprise modules, run any data transformation needed (mostly accounting localization upgrades), and verify. Most of this is handled by Odoo's standard upgrade path if you're on Odoo Online or Odoo.sh.
What happens to the legacy system after cutover?
Common practice: keep it running as read-only for 90–180 days. Users and accountants can still look up historical records. After the safety period, archive the database (PostgreSQL dump, MySQL dump, or whatever the source format is) and decommission the running system. The archive is preserved indefinitely for audit and tax purposes — typically 7–10 years per local regulation.
How do I migrate custom fields from a legacy system?
If the custom field maps to an existing Odoo field (different name, same purpose), map it during transformation. If the custom field has no Odoo equivalent, add it via Odoo Studio (point-and-click custom field) or a small custom module. Avoid leaving custom data behind in the legacy system — that's where unrecoverable data loss happens.
What about historical reports? Can users still pull last year's sales report?
Two options. Option A: migrate historical sales as posted journal entries (no inventory or transactional integrity, just accounting). Reports work, but the data is opaque. Option B: keep the legacy system for historical reports, build new reports in Odoo for forward-going periods. Most retailers choose B — historical reports are pulled rarely; it's not worth complicating the migration.
Can I migrate while my business is running, with minimal downtime?
Yes — with a parallel run pattern. Both systems operate for a period; data migrates incrementally; cutover happens once Odoo is verified. The cost is operational complexity (entering each transaction in two systems for the parallel period) and reconciliation work. For businesses that genuinely cannot pause for a few hours of cutover, it's the right pattern. For most retailers, a Saturday morning big-bang cutover is simpler and has lower total cost.