All posts
Integrator11 min read

Odoo data migration strategies: importing clean data at go-live

A practical guide to migrating business data into a fresh Odoo instance - from mapping source fields to handling open balances, running test imports, and avoiding the post-go-live data corrections that cost weeks of cleanup.

Data migration is the part of an Odoo implementation that takes the least time in the budget and causes the most problems after go-live. Teams underestimate complexity, rush the import, and spend the first month fixing customer records, wrong opening balances, and product configurations that were incomplete in the source system.

This guide gives you a structured approach: what to import, in what order, how to validate it, and what to leave out of the automated import entirely.

What to import and what to configure manually#

Not everything in the old system belongs in Odoo via import. Before writing a single migration script, decide which data categories each approach fits:

CategoryImport via CSV/API?Configure manually?
Customers and vendorsYesNo
Products and variantsYesNo
Open sales ordersUsually not - re-enter active onesManually for < 50
Open invoices and billsYes (as journal entries)No
Historical invoicesOptional - only if required for AR reporting
Opening stock balancesYes (inventory adjustment)
Opening bank/cash balancesYes (journal entry)
Employee recordsYes
Chart of accountsYes (template or CSV)
Payment terms, taxes, pricelistsConfigure manually
Workflows, approvals, sequencesConfigure manually

The rule: import master data (who, what) and opening balances. Do not import transactional history unless there is a specific reporting requirement that requires it.

Migration order matters#

Odoo's relational model means import order must respect foreign key dependencies. The standard sequence:

1. Chart of accounts (res.partner and account.account)
2. Payment terms (account.payment.term)
3. Taxes (account.tax)
4. Product categories and pricelists
5. Products (product.template + product.product)
6. Customers and vendors (res.partner)
7. Employees (hr.employee)  -  if HR is in scope
8. Opening balances (account.move as journal entries)
9. Open receivables and payables (account.move  -  posted invoices/bills)
10. Opening inventory (stock.quant or inventory adjustment)

Importing in the wrong order creates orphaned records or silent failures (e.g., a customer imported before their payment terms exist will silently get the default terms, not the intended ones).

Preparing source data#

Extract and map fields#

Export your source system's data to CSV or Excel. Create a mapping document that shows:

  • Source field name → Odoo field name
  • Source field type → Odoo field type
  • Transformation logic (e.g., "Active = 1" → Odoo active = True)
  • Default values for missing source data

Pay attention to:

Unique identifiers: Odoo does not prevent duplicate partner names. Add an x_legacy_id custom field to res.partner and populate it with the source system's customer ID. This lets you deduplicate and update records after import without guessing which "ABC Company" is which.

Country and state codes: Odoo expects ISO 3166 country codes (res.country) and state codes (res.country.state). Your source system may use different codes or full names. Map them explicitly - a wrong country on a partner breaks tax fiscal position logic.

Currencies: if your source system uses currencies that are not yet active in Odoo, activate them before importing.

Clean the data before importing#

Run these checks on the source data before any import:

  1. Duplicate check: flag duplicate emails, VAT numbers, and legacy IDs.
  2. Completeness check: required fields (partner name, product name, account code) must be non-null.
  3. Reference integrity: every foreign key reference in your import file must exist as a record in Odoo.
  4. Encoding: export source data as UTF-8. Non-ASCII characters in partner names or product descriptions will corrupt if the encoding is wrong.

Importing via CSV#

Odoo's built-in import (any list view → Import) handles CSV. Use it for small datasets (< 5,000 rows) where the transform is simple.

CSV headers#

Odoo's import uses field technical names as headers:

name,email,phone,vat,country_id/name,property_payment_term_id/name,x_legacy_id
ABC Company,contact@abc.com,+1 555 0100,US12345678,United States,30 Days,CUST-001

Use / to traverse relations: country_id/name looks up the country by name. country_id/code looks up by ISO code. For many2many fields (e.g., product tags), use comma-separated values in the same cell and check the import UI for the correct syntax.

Handling external IDs#

Import files can include an External ID column (the id column in Odoo CSV format). This is an ir.model.data record that links the CSV row to an Odoo record ID. With external IDs:

  • Re-importing the same file updates records instead of creating duplicates.
  • You can reference related records by their external ID in other import files.

Use a namespace convention: __import__.partner_CUST001. The __import__. prefix marks them as migration data, separating them from module-defined XML IDs.

Importing via the Odoo XML-RPC API#

For large datasets (> 5,000 rows) or complex transformations, write a Python migration script using the xmlrpc.client or odoorpc library:

python
import xmlrpc.client

url = 'https://your-odoo.instance'
db = 'erpeek'
username = 'admin@example.com'
password = 'yourpassword'

common = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/common')
uid = common.authenticate(db, username, password, {})
models = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/object')

# Create partners in batches
batch_size = 100
for i in range(0, len(partner_data), batch_size):
    batch = partner_data[i:i+batch_size]
    partner_ids = models.execute_kw(db, uid, password, 'res.partner', 'create', [batch])
    print(f"Created partners {i}–{i+batch_size}: {partner_ids}")

Batch in groups of 50–200 records. Creating records one at a time makes N round-trip HTTP requests - for 10,000 partners this takes hours. Odoo's create method accepts a list of dicts and creates all records in a single call.

Commit between batches. Each create call runs in a transaction. If the batch fails, only that batch rolls back. You can resume from the last successful batch.

Opening balances#

Chart of accounts opening balance#

Post a single journal entry dated the day before go-live with:

  • Debit: each asset account (cash, AR, inventory, fixed assets)
  • Credit: each liability and equity account (AP, loans, retained earnings)

The entry must balance. Work with the client's accountant to get the trial balance from the old system as of the cutover date.

python
# Create the opening balance journal entry
entry = models.execute_kw(db, uid, password, 'account.move', 'create', [{
    'move_type': 'entry',
    'date': '2026-01-01',
    'journal_id': opening_journal_id,
    'ref': 'Opening Balance - Migration',
    'line_ids': [
        [0, 0, {'account_id': cash_account_id, 'debit': 50000, 'credit': 0}],
        [0, 0, {'account_id': ar_account_id, 'debit': 120000, 'credit': 0}],
        [0, 0, {'account_id': ap_account_id, 'debit': 0, 'credit': 80000}],
        [0, 0, {'account_id': equity_account_id, 'debit': 0, 'credit': 90000}],
    ],
}])
models.execute_kw(db, uid, password, 'account.move', 'action_post', [[entry]])

Open receivables and payables#

Import open customer invoices and vendor bills as individual account.move records in posted state. Each invoice should reference the correct partner, due date, and invoice lines. After posting, Odoo's AR/AP aged reports will reflect the correct outstanding amounts.

Do not create these as journal entries to the AR account directly - that bypasses the reconciliation tracking and breaks the aged report.

Opening inventory#

Use Odoo's inventory adjustment feature (Inventory → Operations → Physical Inventory) or import stock.quant records via the API. Each quant needs: product, location, quantity, and optionally lot/serial.

python
# Set opening stock via inventory adjustment
models.execute_kw(db, uid, password, 'stock.quant', 'create', [{
    'product_id': product_id,
    'location_id': stock_location_id,
    'quantity': 50.0,
    'lot_id': lot_id,  # If tracked
}])

Validate the adjustment to post the inventory valuation journal entry.

Validation checklist before go-live#

Run these checks after each migration run, in a copy of the production database:

  • Partner count matches source system export (within known dedup removals)
  • Product count matches (including variants)
  • Trial balance in Odoo matches client-signed cutover trial balance
  • AR aged report total matches open invoices import total
  • AP aged report total matches open bills import total
  • Stock valuation report matches opening inventory value
  • A sample of 20 partners: verify name, email, phone, VAT, country, payment terms
  • A sample of 20 products: verify name, UoM, sales price, purchase price, category

Common mistakes#

Importing historical transactions for reporting. Historical orders, invoices, and shipments from the old system are almost never worth importing. They create massive data volumes, slow down the database, and the old data doesn't match Odoo's relational structure cleanly. Use a BI tool or archive the old system for historical reporting.

Not using external IDs. Without external IDs, a re-import after a data fix creates duplicates. External IDs are essential - add them from the start.

Importing open orders instead of re-entering them. Open sales and purchase orders have complex states, approvals, and delivery links. It is almost always faster to re-enter the 20-50 active orders manually than to build a migration script for them.

Not involving the client's accountant in the opening balance. The opening journal entry must be signed off by the accountant before go-live. Surprises after go-live ("the VAT account doesn't match") are expensive to fix in a live system.


For the API access patterns used in migration scripts, see the Odoo external API guide. For the chart of accounts and tax setup that must exist before importing transactions, see the accounting for non-developers guide.

Try ERPeek on your own Odoo module - ask questions, scaffold tests, and explore your codebase in plain language.

Get started free