Section 01
Why teams migrate to Odoo ERP
The four shapes an Odoo ERP migration takes, and what makes the platform easier — or harder — than the category average.
Odoo S.A. was founded by Fabien Pinckaers in 2005 as TinyERP, rebranded to OpenERP in 2009, then to Odoo in 2014, and is headquartered in Ramillies-Brabant-Wallon, Belgium 1. Odoo ERP is the integrated finance, inventory, manufacturing and procurement layer of a suite of around 80 official modules — Accounting, Inventory, Manufacturing, Purchase, Sales, Project, HR — all sharing the same PostgreSQL database and ORM.
The typical Odoo ERP customer is a small-to-mid-market manufacturer, distributor or services business — 25 to 500 users — that wants general ledger plus inventory plus production plus AP/AR under one schema, and is willing to absorb the open-source learning curve for the integration depth 2. Compared with SAP Business One or Dynamics 365 Business Central, Odoo positions on per-user cost; compared with NetSuite, on module breadth and a unified data model 3.
The shapes of migration that actually land on Odoo ERP tend to fall into four patterns. First, suite consolidation projects: a company replacing a stack of QuickBooks plus a separate WMS plus a spreadsheet-based MRP with the Odoo all-in-one suite. Second, vendor-cost exits from SAP, NetSuite or Oracle where total cost of ownership at 50–400 users no longer justifies the enterprise contract 5.
Third, legacy ERP replacements — older OpenERP/TinyERP installs, Sage 50, JD Edwards on-prem, JTL-Wawi, or country-specific country-by-country systems — where the source schema is loose and the project is really a re-architecture against account.move and stock.move 6. Fourth, Odoo-to-Odoo version migrations, where a team running Community 15 jumps to 18 or 19, or moves from Odoo.sh Enterprise back to self-hosted Community, with OpenUpgrade carrying the schema 7.
Each shape has a different difficulty profile: a QuickBooks-to-Odoo move usually has clean GL parity but messy chart-of-accounts numbering, while a SAP-to-Odoo migration carries rich automation that does not move across at all.
What makes migrating *to* Odoo ERP easier than the category average is the import tool itself — it accepts CSV and Excel files, auto-detects column types, supports relational fields via XML external IDs, and ships built into every model's list view through Actions → Import records 8. Pre-built templates exist for Contacts, Products, Chart of Accounts, Journal Entries and Bills of Materials.
What makes it harder than the average is the Community-vs-Enterprise gap — full Accounting (audit trail, follow-up letters, online bill scanning), Studio, Documents, Marketing Automation and several connector modules are Enterprise-only, and silently disappear if you restore an Enterprise database into a Community container 7. The second hard edge is the annual major-version cadence: every 12 months a breaking release ships, which means migration is not a one-time event but a recurring workstream every team plans around 11.
Studio customisations, Automated Actions, custom Python modules and country-specific localisations do not move with a CSV import — they are rebuilt as modules and configured in the destination. Teams that scope for that work up front finish on time; teams that assume parity do not.
Studio customisations, localisations and Automated Actions do not move with a CSV import — they are rebuilt in the destination.
Section 02
The Odoo ERP data model you need to map into
Models, ondelete semantics, and the external IDs you'll wire on every relational column — the destination schema decoded.
Odoo's ERP is built around a small set of Python models, each declared as a class extending models.Model with attributes that the ORM persists into PostgreSQL 12. Finance, inventory and manufacturing sit on top of the same shared res.partner master record and account.account chart of accounts used by every other Odoo app.
Before you can map a field on the source side, you need to know exactly which destination model the row belongs on, what fields it requires, and which value will serve as its external ID for upsert. The table below summarises the models you will touch in an Odoo ERP migration 13.
| Object | Stores | Required on import | Tier |
|---|---|---|---|
| res.partner | Master record for customers, vendors and employees | name; is_company; customer_rank / supplier_rank | All editions |
| account.account | Chart of Accounts — every GL account | code; name; account_type (income, expense, asset, liability, equity) | Full Accounting in Enterprise; basic Invoicing in Community |
| account.move / account.move.line | Journal entries — invoices, bills, opening balances, all GL postings | journal_id; date; line_ids (debit/credit balanced) | All editions |
| account.journal | Journals — Sales, Purchase, Bank, Cash, Miscellaneous | name; type; code | All editions |
| product.template / product.product | Product master and per-variant SKUs | name; type (consu / service / product); list_price | All editions |
| uom.uom / uom.category | Units of Measure (kg, m, box of 6) and their conversion categories | name; category_id; factor | Inventory module |
| stock.location / stock.warehouse | Physical and logical inventory locations | name; usage (internal, customer, supplier, inventory) | Inventory module |
| stock.quant / stock.move / stock.lot | On-hand quantities, stock movements, and lot/serial numbers | product_id; location_id; quantity | Inventory module |
| mrp.bom / mrp.bom.line | Bills of Materials and their components | product_tmpl_id; product_qty; bom_line_ids | Manufacturing module |
| purchase.order / sale.order | Purchase and sales orders with their order lines | partner_id; date_order; order_line | Purchase / Sales modules |
| res.currency / res.currency.rate | Currencies and dated exchange rates | name (ISO code); rate; date | All editions |
| res.company | Legal entity — drives multi-company isolation | name; currency_id; country_id | All editions; Enterprise required for inter-company rules |
res.partner is the unifying record — a single row represents a customer, a vendor, an employee, or a contact at any of those, distinguished by the customer_rank and supplier_rank integer counters and linked through parent_id for company-and-contact hierarchies 14.
Odoo does not have a single canonical natural-key field on most models. Instead, the import tool uses an XML external ID — a string of the form __import__.123 or your_module.account_4000 — stored in the id column on the import spreadsheet. The external ID is the upsert key: re-importing the same row with the same external ID updates; a new external ID creates.
If you do not supply external IDs, Odoo generates them automatically and you lose the ability to deterministically re-import. For native dedup on res.partner, install the Data Cleaning app and configure dedup rules on email, phone, or VAT number. Custom field types determine validation and storage; the catalogue below covers what you can model and the limits you need to plan around 12.
| Field type | Limits | Notes |
|---|---|---|
| Char (single-line text) | Default size=64, configurable | Maps to PostgreSQL varchar; trim before import |
| Text (multi-line text) | Unlimited (PostgreSQL TEXT) | Used for descriptions, internal notes |
| Integer / Float | INT4 / NUMERIC in PostgreSQL | Float defaults to digits=(16,2); override per field for GL precision |
| Monetary | Tied to res.currency precision | Use for amounts; system handles rounding by currency decimals |
| Date / Datetime | YYYY-MM-DD / YYYY-MM-DD HH:MM:SS in UTC | Datetime stored UTC; ISO-8601 strongly recommended |
| Selection (picklist) | Defined as Python tuple list | Internal key vs label; import expects the key 12 |
| Many2one (foreign key) | Single related record | Resolves by name OR external ID OR database ID — pick one and be consistent 17 |
| One2many / Many2many | Inverse / junction relations | Many2many uses comma-separated names on import 18 |
| Binary (attachments) | 64 MiB default on Odoo.sh; 128 MiB on self-hosted | Stored as ir.attachment records 19 |
| Custom fields via Studio | 21 field-type variants, Enterprise-only UI | Community: edit via Developer Mode → Settings → Technical → Fields 20 |
Relationships in Odoo are modelled by relational fields with explicit ondelete semantics: cascade deletes children when the parent is deleted, restrict blocks the parent delete, set null clears the foreign key 21. This is configured in fields.py per field, not at the database level you can flip from the UI — and it drives several of the pitfalls in Section 6.
Custom Objects in Odoo are not a separate concept — they are simply new Python models, declared in a custom module's models/ folder, or created via Studio (Enterprise) which writes the same metadata into ir.model and ir.model.fields 20. Either way, the model must exist in the database before records of that type can be imported, and the module must be installed before the model is reachable.
Section 03
Pre-migration prep — the work before you touch Odoo ERP
What must be true on the source, the destination, and across the team before the first row hits the import wizard.
The single best predictor of a clean Odoo ERP migration is how much work you do on the source side before the first import button is pressed. Odoo's own implementation partners warn that data preparation — not the upload itself — is where most projects lose weeks, and the fix is almost always pre-processing rather than post-cleanup.
The single best predictor of a clean migration is how much work you do before the first import button is pressed.
Treat the source export as raw material that needs to be shaped to Odoo's expected formats — dates rewritten to YYYY-MM-DD (ISO-8601), Selection values converted to their internal keys, Many2one relations resolved to either a name string or an external ID, currencies resolved to ISO codes that exist in res.currency, GL accounts mapped to the destination chart of accounts, and every row stamped with a stable id column.
Source-side prep
- Audit and dedup the customer and vendor master in the source system before export. Odoo's Data Cleaning app handles post-import dedup on
res.partnermatching on email, phone and VAT, but it is much cheaper to dedup on the source than to merge after. - Reconcile the source GL to its trial balance before extracting opening balances — every account that does not tie out on the source becomes a manual line item on the Odoo opening journal entry.
- Normalise dates to
YYYY-MM-DDorYYYY-MM-DD HH:MM:SSin UTC. Odoo's importer auto-detects most formats but day/month inversions like01-03-2016are ambiguous; use ISO-8601 to remove the guesswork. - Convert Selection field labels to internal keys — for account types, journal types, product types, every custom dropdown — Odoo expects the Python key (e.g.
asset_receivable,liability_payable,consu,product), not the display label 12. - Stamp a stable external ID in an
idcolumn on every export row — a UUID, the source platform's primary key, or a namespaced string likelegacy.account_4000. This is what makes re-runs and reconciliation deterministic. - Decide what is in scope for historical transactions. Open AP/AR invoices and the opening trial balance must move; closed prior-year journal entries usually stay in the source as a read-only archive.
Destination-side prep
- Install the country localisation under Apps → search
l10n_(for examplel10n_us,l10n_fr,l10n_de) on a fresh database *before* any other Accounting setup — the localisation seeds the chart of accounts, taxes, and fiscal positions, and cannot be cleanly applied after journal entries exist 27. - Create a staging database on Odoo.sh (staging branches are first-class) or duplicate the database on Odoo Online or on-prem to dry-run the import end-to-end. Restore a sanitised copy of production into staging weekly during the project.
- Provision users first under Settings → Users & Companies → Users, assigning each to Accounting, Inventory, Manufacturing and Purchase groups. Owner assignment fails silently if the referenced user does not exist.
- Pre-create every custom field on the right model via Studio (Enterprise) or Developer Mode → Settings → Technical → Fields (Community). Selection fields must have their full key list defined before any value is imported 20.
- Build the chart of accounts, journals, fiscal years, and tax codes in Accounting → Configuration before importing any journal entries — entries targeting a non-existent account or a closed period are rejected.
- Install required modules first — Accounting, Inventory, Manufacturing, Purchase, Sales, plus any OCA modules (OCA Audit Log, fs_attachment, OpenUpgrade) you intend to depend on. Trying to import to a model whose module is not installed throws
Model not found.
People prep
Cutover only works if humans cooperate. Lock down a source-system freeze window — typically 24 to 72 hours for finance, longer for inventory if you are recounting — and communicate it to every department that touches the ERP. Train AP clerks on the Odoo vendor bill workflow, warehouse staff on the kanban barcode flow, and accountants on the journal-entry and reconciliation widgets before go-live, not after.
A typical 50-user professional-services Odoo migration runs four to eight weeks of elapsed time end-to-end; a 200-user multi-company manufacturing migration with Studio rebuilds, multi-currency consolidation and historical inventory loads runs three to six months 30. Build the human runway accordingly.
Section 04
Import mechanisms: UI wizard, base_import, and scripted loads
Multiple paths in, each with different limits and shapes. Picking the wrong one is how mid-migrations stall at the throughput ceiling.
Odoo exposes several load paths and the right one depends on dataset size, model mix, and whether you need to re-run idempotently. The UI Import wizard (powered by the base_import module) covers most one-shot migrations under a few hundred thousand records. Scripted loads via the Odoo ORM handle programmatic, repeatable and very large loads. Third-party tools sit on top of both and add staging, transformation and dedup layers.
UI Import wizard (base_import)
The native import lives on every model's list view: open the model (Contacts, Accounting → Chart of Accounts, Inventory → Products, etc.), click the gear or Actions icon, then Import records → Upload File 8. The wizard accepts CSV and Excel (XLSX) files, auto-detects column-to-field mapping by name, exposes a Show fields of relation fields (advanced) toggle to see related model fields, and offers a Test mode that validates the file without writing rows.
There is no hard daily row cap in base_import, but practitioner reports suggest CSV-via-UI is reliable up to roughly 10,000 records per file and starts to hit worker timeouts above 50,000 on Odoo.sh and Online. The right call: UI for one-shot loads under 100k records on standard models, or any time you want a visual mapping review and a Test pass before commit.
For Excel files, save as .xlsx (not .xls) — older formats are less reliable. Re-imports against the same external ID in the id column update existing rows; new external IDs create. Warning: imports are permanent and cannot be undone from the UI — use the created on or last modified filter to locate import-batch rows for archival 17.
Scripted loads through the Odoo ORM
Odoo's ORM exposes the same methods as the UI — create, write, search_read, and the bulk-friendly load — and can be driven by scripts running against an Odoo instance. The load() method is what base_import calls internally; it accepts a list of field names and a list of value rows and applies the same external-ID upsert semantics as the UI wizard. For bulk loads, batch records into chunks of 100 to 500 per create or load call; smaller batches add round-trip overhead, larger batches risk timeouts on Odoo.sh. Model.create() accepts a list of dictionaries — bulk-insert in a single call rather than a loop 33.
Choose the scripted path when the load is over 100k records, when custom models are involved, when the migration needs to be repeatable and audit-logged from your side, or when you are syncing from an external warehouse. Wrap calls in a back-off loop on throttling and connection-reset errors.
Third-party staging tools and OpenUpgrade
Tools like Fivetran, Airbyte and Stacksync ship Odoo connectors that sit between the source and the destination. They are commonly used in two ways: (a) reverse-ETL where the source database is loaded into a warehouse, transformed in SQL, then synced into Odoo; and (b) staging-and-validate, where the tool generates the import CSV, runs type coercion, and feeds the result to the wizard.
For Odoo-version migrations specifically (Community 15 → 18, etc.), the Odoo Community Association's OpenUpgrade project provides the only working open-source upgrade scripts for Community editions 7. For Odoo Enterprise upgrades, Odoo S.A.'s paid upgrade service handles the schema transformation but is not available to Community users — and the annual major release cadence makes this a recurring cost line rather than a one-time event 11.
Under 10,000 records on standard models → UI base_import wizard. 10,000–100,000 → UI wizard in batched files. Over 100,000, any custom models, opening-balance journal entries, or any re-runnable load → scripted ORM load() calls in chunks with a back-off loop.
Section 05
Mapping your data into Odoo ERP
The longest section — because field mapping is where almost every ERP migration that fails actually breaks.
Mapping is where every ERP migration earns its scars. The schema decisions you make in your mapping spreadsheet determine whether the trial balance ties on day one, whether inventory valuation matches on day five, and whether the closing process works at the first month-end after go-live.
Work model by model, top to bottom of the dependency order: res.company and res.currency first, then res.users, then account.account (chart of accounts) and account.journal, then customers and vendors on res.partner, then product.template with uom.uom, then stock.location and on-hand inventory, then mrp.bom, then open AP/AR via account.move, and finally the opening trial balance.
Chart of accounts (account.account)
Install the country localisation module (e.g. l10n_us, l10n_fr, l10n_de) before any chart-of-accounts work — the localisation seeds the legally required CoA structure, default taxes and fiscal positions 27. Then map the source CoA to the localised CoA: most projects keep the localised account codes and only add custom accounts for operational tracking (departments, projects, cost centres) that the source had as analytic dimensions.
Common source → account.account mapping
- GL account number→code
Renumber to fit localised CoA ranges; document the cross-walk
- account name→name
Required; keep source name as
legacy_nameChar if useful - account type / category→account_type
Selection key: asset_current, asset_receivable, liability_current, liability_payable, income, expense, equity 27
- reconcilable flag→reconcile
Set True on AR/AP control accounts; required for invoice matching
- currency restriction→currency_id
Only set if account is restricted to a single non-company currency
GL opening balances (account.move)
Opening balances post as a single Miscellaneous journal entry on account.move dated the day before go-live (e.g. 2026-01-01 entry on a 2026-02-01 cutover), with one account.move.line per account, debits equal to credits 35. The exception is AR and AP control accounts — those balances post as individual open invoice and bill records (next subsection), not as a single line, so that future receipts and payments can match by document.
Recommended sequence on the opening entry: assets first (cash, inventory control, fixed assets net of accumulated depreciation), then liabilities (accrued expenses, taxes payable, loans), then equity (retained earnings as the balancing account). The journal must balance to the penny — Odoo rejects unbalanced entries with The total of the debits is not equal to the total of the credits.
Open AP / AR invoices (account.move with move_type)
Open customer invoices import as account.move records with move_type='out_invoice' and state='posted'; open vendor bills use move_type='in_invoice'. Each invoice carries its own due date, partner, lines, tax and amount-residual — and crucially, its own original invoice date so that aging reports work correctly from day one.
Build one row per invoice header plus one row per line item (One2many on invoice_line_ids), with the invoice's external ID repeating on each line. After import, run the Accounting → Reporting → Partner Ledger report and reconcile against the source AR/AP aging — any partner whose balance does not match within rounding tolerance flags a mapping defect to fix before go-live.
Inventory: products, UoM, locations, on-hand quantities
Inventory mapping touches four models in sequence. First, uom.uom and uom.category — every UoM you reference must already exist, and conversions only work between UoMs in the same category (Units, Weight, Volume, Length, Time) 36. If you sell in meters and buy in centimetres, both must be in the Length category with the correct factor.
Second, product.template and product.product — the template is the catalogue item, the product.product is the variant (size, colour, configuration). Required fields: name, type (Selection key: consu, service, or product for storable), list_price, uom_id, uom_po_id, and categ_id (product category). Set tracking to lot or serial if the SKU needs lot/serial tracking — this must be decided *before* the first stock move exists for that product, because Odoo blocks the change once movements have posted 37.
Third, stock.warehouse and stock.location — warehouses contain a tree of locations (Stock, Stock/Shelf A, Stock/Quality Control, Customers, Vendors, Inventory adjustment). Each location has a usage (Selection key: internal, customer, supplier, inventory, production, transit).
Fourth, on-hand inventory — the recommended path is Inventory → Operations → Physical Inventory which creates an Inventory Adjustment that posts as a stock.move from the virtual Inventory adjustment location into the real internal location, with the per-product quantity and lot/serial number where applicable. The corresponding GL entry hits the inventory control account on the opening journal entry — do not double-count by posting both the stock.move and a manual GL line for the same value.
Bills of materials (mrp.bom)
BOMs import as mrp.bom headers (one row per assembly) plus mrp.bom.line children (one row per component). The header carries product_tmpl_id, product_qty, code, and type (Selection key: normal, phantom for kit-bill, subcontract). The lines carry product_id, product_qty, and product_uom_id 39.
BOM imports are bottom-up: every component product must exist before the BOM that references it, and multi-level BOMs need the sub-assembly's BOM imported before the top-level BOM that includes the sub-assembly. Practitioners report BOM creation is one of the heavier operations because each BOM needs the header create, then one create per line — under Odoo Online's tighter throttling, even a few thousand BOMs becomes hours 22.
Multi-currency and historical exchange rates
Multi-currency is enabled in Accounting → Configuration → Settings → Currencies → Multi-Currencies and configured per-company through res.company.currency_id. For historical transactions in foreign currency, you import the original amount and original date in amount_currency and date, and Odoo posts the company-currency equivalent using the rate from res.currency.rate for that date — so the rate table must be populated for every historical date that has a foreign-currency transaction.
Without dated rates, Odoo falls back to the current rate and posts the wrong company-currency value 41. Build the rate import as one row per (currency, date, rate) triple and import it before any multi-currency invoices or journal entries. For multi-company setups, each res.company can have its own functional currency, and inter-company transactions revalue automatically.
Fiscal year and period close
Fiscal year is configured in Accounting → Configuration → Settings → Fiscal Periods (last day of fiscal year, lock dates). Set the Journal Entries Lock Date to the day before your opening-balance date so that nobody (including the import job) can post prior-period entries by accident 27. After opening balances and open invoices are loaded and reconciled, advance the lock date to the day before the new period — this is how Odoo enforces period close.
Custom-field mapping strategy
Resist the urge to map every source custom field one-to-one. Migrate only the custom fields used by an active process, automated action, or report in the last 12 months. Excess fields slow down list views and make the model harder to upgrade between Odoo versions because every custom field has to round-trip through OpenUpgrade or the paid upgrade service 7.
For Selection fields whose source values do not match the destination, either: (1) extend the destination Selection with the missing internal keys via Studio or fields.py, (2) collapse adjacent values during transform, or (3) introduce a parallel legacy_value Char field that holds the source value verbatim. Computed fields do not import — Odoo recomputes them on save — so any source formula must be rebuilt as a computed field on the model or replicated via Server Actions.
Files, attachments, and audit trail
Attachments in Odoo live on the ir.attachment model and link to any record via res_model and res_id. Default per-attachment cap is 64 MiB on Odoo.sh and Odoo Online (the public reverse proxy enforces this) and 128 MiB on self-hosted instances that override server config 19. Supported image formats include JPEG, PNG, GIF, BMP, TIFF, SVG, ICO, WEBP, PSD and EPS 42.
Bulk attachment import is not part of base_import — the supported path is either (a) the Documents app (Enterprise) which exposes a drag-and-drop bulk uploader, (b) the fs_attachment OCA module which moves the filestore to S3 or an external filesystem 43, or (c) scripted upload through the ORM where you load ir.attachment records with datas, name, res_model and res_id set.
Odoo records create_date, create_uid, write_date and write_uid automatically and does not preserve the source record's original creation date during import — create_date stamps to upload time 44. If you need the original audit trail, create legacy_create_date (Datetime) and legacy_create_uid (Many2one to res.users) custom fields per model and populate from the source export. Field-change auditing is provided by the Enterprise Accounting Audit Trail feature, and the OCA Audit Log module fills the same role on Community 45.
Section 06
The pitfalls that derail Odoo ERP migrations
Ten specific failure modes — ranked by impact, each tied to the exact Odoo mechanism that breaks.
High impact
Localisation installed after journal entries already exist
The l10n_* localisation modules seed the chart of accounts, default taxes and fiscal positions. They are designed to be installed on a fresh database; once journal entries reference accounts, applying or switching a localisation throws constraint errors and partially-loaded CoA rows that the UI cannot cleanly delete 27. The recommended path is: spin up a fresh database, install the country localisation first, then run every other migration step. Switching localisation mid-project usually means restoring from backup. 27
High impact
Enterprise modules silently disabled on a Community restore
Restoring an Odoo.sh Enterprise database into a self-hosted Community container marks Enterprise-only modules — Accounting (full), Documents, Studio, Marketing Automation, Audit Trail — as 'installed' but 'not installable, skipped'. The UI crashes with errors like View types not defined map found in act_window and Studio customisations vanish from form views 7. Before you switch hosting tiers, list every installed module via Settings → Apps → Installed and confirm each has a Community equivalent or a replacement plan. 7
High impact
Opening journal entry does not balance to the penny
Odoo rejects any account.move whose total debits do not equal total credits with The total of the debits is not equal to the total of the credits and refuses to post. Rounding differences from a foreign-currency source ledger, fractional cents in inventory valuation, or accumulated retained-earnings calculation off by 0.01 are the usual culprits. The fix is a single 'Rounding Adjustment' line posted to a designated rounding account on the opening entry — do not start chasing the penny across operational accounts. 35
High impact
Selection field label vs internal key mismatch
Odoo Selection fields — account.account.account_type, product.template.type, account.move.move_type, every custom dropdown — store an internal Python key distinct from the UI label. The label is Receivable; the key is asset_receivable. The import wizard rejects label-only payloads with a vague Unsupported format character or silently coerces to a default. Build your transform layer against the model's selection tuple in code (Developer Mode → Settings → Technical → Fields), never against what an admin sees in the UI 12. 12
High impact
Throughput throttling on Odoo Online slows large loads
Practitioners testing importers on Odoo 17 report the Odoo Online terms of use enforce tight throughput throttling 22. Combined with Odoo's standard 'creating more than 2-3 sales orders per second per worker is difficult' guidance even on Enterprise, a BOM-and-inventory load can take days instead of hours. Drop concurrency to a single worker on Odoo Online and rely on chunked load() calls of 100–500 rows; on Odoo.sh and self-hosted, scale parallelism against your server's worker count. 22
High impact
Product tracking changed after the first stock move exists
Setting product.product.tracking to lot or serial is irreversible once stock has moved against that product. If you import inventory without tracking enabled and later need to enable lot tracking, Odoo blocks the change with You can only change the tracking of a product on a product without quantities on hand — and your remediation is to liquidate all stock to a virtual location, change tracking, then re-import with lots 37. Decide tracking model per product before the first stock.move posts. 37
High impact
Multi-currency without dated exchange rates loads wrong company-currency values
When a multi-currency invoice imports with currency_id set to a foreign currency, Odoo uses the rate on res.currency.rate for the invoice's date to compute the company-currency equivalent. If the rate for that date does not exist, Odoo falls back to the most recent available rate and posts a materially wrong number — and the field-level error log gives no warning 41. Populate res.currency.rate for every distinct invoice date in the foreign-currency dataset *before* importing the invoices. 41
Medium impact
create_date cannot be overridden on import
Odoo does not preserve the source record's creation date during import — create_date, create_uid, write_date and write_uid are ORM-managed and stamp to upload time and the import user 44. Teams discover this on day two when reports filtered by *Created Date* return everything stamped to import day. The mitigation is to create legacy_create_date and legacy_write_date custom Datetime fields on every imported model, populate them from the source export, and rewrite historical-period reports to use the legacy fields. 44
Medium impact
Date format auto-detect inverts day and month
Odoo's importer auto-detects date columns but warns that day-month inversions like 01-03-2016 are ambiguous and may be guessed wrong. Worse, error messages from XLSX imports surface as Column activity_ids/date_deadline contains incorrect values. Error in line 1: unconverted data remains: 2019-12-16 with no field-level hint about what went wrong 46. Always set the Date Format explicitly in the wizard's Formatting Options to ISO-8601 (YYYY-MM-DD), or convert to ISO before export.
Medium impact
ondelete=restrict blocks parent deletion across the dependency chain
Relational fields in Odoo specify ondelete='cascade', 'restrict' or 'set null' per field 21. If you import test data into a sandbox, then try to delete a parent record (a res.partner referenced by an account.move, a product.template referenced by a stock.move), Odoo blocks the delete with The operation cannot be completed: Another model is using the record you are trying to delete 47. Plan archival, not deletion, for parent records — use the active=False boolean to soft-delete. 21
Low impact
Annual major-version cadence forces a recurring migration workstream
Odoo ships a breaking major release every 12 months. Each release ages out community modules, deprecates ORM patterns and may shift schema in localisations 11. A migration in 2026 to Odoo 19 will face an OpenUpgrade or paid-upgrade event in 2027 to reach Odoo 20, in 2028 for 21, and so on. Treat migration as an ongoing capability rather than a one-time project; lock customisations into named modules with manifest version pins, and budget upgrade cycles into the operating cost from year one. 11
Low impact
Data residency is fixed at signup on Odoo Online and Odoo.sh
Odoo.sh has seven Google Cloud regions — Iowa, Toronto, Belgium, Dammam, Mumbai, Singapore, Sydney — and Odoo Online assigns customers based on region at signup. There is no bring-your-own-cloud option, no African data centre, and changing region after signup requires a support-managed migration that schedules in weeks. If your project requires EU residency under GDPR or India DPDP residency and the account was created in the wrong region, plan the regional move *before* the data migration, or move records twice.
Section 07
Validation and cutover
What to verify after the import job, in what order — and how to fail safely when something is wrong.
Validation is the bridge between the import finishing and users being allowed in. Odoo implementation partners recommend a three-stage validation: a test load of 10 percent of records with stakeholder spot-checks, the full load with real-time monitoring of GL balance and stock counts, and a 30-day post-migration data-quality audit. The most reliable signal is having finance, AP, AR and warehouse leads verify their own records against the source.
Build a reconciliation queries spreadsheet that compares source and destination on each of these counts. Anything outside a 0.5 percent variance — or any non-zero variance on the trial balance — gets investigated before users get login access.
- Trial balance match — run Accounting → Reporting → Trial Balance on Odoo dated the opening-balance date and reconcile every account to the source trial balance. Any non-zero variance is a defect, not a rounding issue.
- AR aging by partner vs source — run Accounting → Reporting → Partner Ledger filtered to receivable accounts and compare partner-by-partner totals against the source AR aging report.
- AP aging by vendor vs source — same as AR, against the source AP aging.
- Stock on hand by warehouse and location vs the source physical count — run Inventory → Reporting → Inventory Valuation and reconcile against the source warehouse management report.
- Inventory valuation vs the source GL inventory control account — these must tie within rounding; any larger variance indicates a unit-of-measure conversion error or a cost-method mismatch (FIFO/Average/Standard).
- Open purchase and sales orders vs source — count distinct
purchase.orderandsale.orderindraftorpurchase/salestate and verify against the source open-orders list. - BOM count and component count —
GROUP BY product_tmpl_idonmrp.bomand compare against the source manufacturing master. - External-ID uniqueness — query
ir.model.datafor duplicate(module, name)combinations on your migration namespace; any duplicates indicate the dedup transform missed cases. - Multi-company isolation — if multi-company is in scope, query each
res.companyand confirmaccount.moverows are scoped to the rightcompany_id.
On top of reconciliation, run a manual spot-check protocol: pick 30 random records across models — five open invoices, five vendor bills, five products with inventory, five BOMs, five sales orders, five journal entries — and verify each field against the source UI. If a non-trivial discrepancy shows up in three or more of the 30, halt the load, fix the root cause, and re-import the affected rows by external ID.
Odoo does not ship a native bulk-undo for imports — the documentation is explicit that imports are permanent 17. The closest things are the OCA Audit Log module (when configured with the right rules in advance) 45, the Enterprise Accounting Audit Trail for journal entries, and Odoo.sh staging branches which let you fork production and roll back at the database level. None is a one-click reversal; all depend on having set them up *before* the import.
The real rollback strategy remains: take a full PostgreSQL backup via /web/database/manager (pg_dump -Fc plus the filestore directory) before the import starts, stamp every imported row with a custom import_batch_id Char field, and if catastrophe strikes, archive-by-batch and re-import from the cleaned source.
Cutover sequencing: (1) source goes read-only and the finance, AP, AR and warehouse teams are notified; (2) physical inventory count completes and loads as opening on-hand; (3) final delta export captures everything that changed during the test-import window; (4) delta imports with the same external-ID namespace; (5) trial-balance and AR/AP reconciliation runs; (6) lock dates advance to the day before go-live; (7) users get login access plus a 48-hour hyper-care window; (8) source decommission schedules 90 to 180 days out.
Section 08
Migration partners and tools
Official partners, OCA modules, iPaaS connectors, specialist migration shops — what each is good for and how to choose.
Odoo S.A. runs a tiered partner programme — Gold, Silver and Ready — based on certifications, customer count and recurring Enterprise revenue, and lists 5,000+ partners across 120+ countries on its public directory 52. For ERP migrations specifically, partners with explicit SAP-to-Odoo, NetSuite-to-Odoo, Sage-to-Odoo or QuickBooks-to-Odoo practices tend to ship cleaner than generalist shops30. The Odoo Community Association (OCA) maintains modules — Audit Log, OpenUpgrade, fs_attachment — that fill gaps left by Enterprise 745.
Cybrosys Technologies, Open Technology Solutions, Synavos, Aspire Softserv, Silent Infotech, Iqra Technology, Softeko, Braincuber, Steersman and ECOSIRE are commonly named in the migration corner of the market and each offers fixed-scope or hourly-rate migration packages alongside ongoing managed services30. Odoo S.A. also runs a paid upgrade service for Enterprise customers, which handles version-to-version schema migration but is not available to Community users — for Community, OpenUpgrade is the working alternative 7.
On the ETL and iPaaS side, Fivetran, Airbyte and Stacksync ship Odoo connectors, and several Odoo Apps Store extensions (Import Bridge by Axis, base_import_async by OCA) add async-job patterns on top of the wizard. Their role in an ERP migration is rarely the migration itself — it is the staging layer, the transformation layer that resolves Selection values and Many2one relations, and the ongoing-sync layer post-cutover.
Managed-migration cost ranges vary widely with user count and module scope. Practitioner pricing for Odoo ERP migrations runs roughly $3,000–$10,000 for small businesses (under 10 users, single-company, standard modules), $20,000–$50,000 for medium businesses (11–50 users, multiple modules with moderate customisation), and $50,000–$100,000+ for large enterprises (50+ users, multi-company, heavy customisation, multiple integrations) 30.
Hourly rates for Odoo professional services range $10–$150+ per hour depending on region and partner expertise. The upper end of the curve is driven by user count, the number of modules in scope (Accounting, Inventory, Manufacturing, Purchase, Sales, HR, Project — each adds elapsed time), the depth of historical data, multi-currency or multi-company complexity, and the number of third-party integrations that need rebuilding rather than re-pointed.
For teams that want to outsource the migration end-to-end, FlitStack specialises in Odoo ERP migrations and handles the localisation install, chart-of-accounts cross-walk, opening-balance journal preparation, inventory and BOM load, multi-currency rate population, and validation work described in Sections 5 and 7 of this guide. Pricing is fixed-fee, based on user count, module scope and source platform, with separate line items for multi-company, historical-data depth and custom modules so the scope is transparent before signature.
This is one of several legitimate paths — the right choice for any given team depends on whether they want an Odoo Gold Partner, the paid Odoo upgrade service, an iPaaS-first approach, an OCA-and-OpenUpgrade route, or a specialist migration vendor. Explore FlitStack →
Section 09
Frequently asked questions
The eight questions every Odoo ERP migration team works through before they sign the scope.
References
Sources
- 1 Odoo — Wikipedia
- 2 Is Odoo the right ERP for my company? — r/Odoo (50M USD / 400 user evaluation)
- 3 SAP wants a 20% price increase — r/NoStupidQuestions (vendor-cost exit)
- 5 Replacing our legacy ERP with Odoo Enterprise — r/Odoo (brick-and-mortar SME)
- 6 JTL-Wawi to Odoo migration war story — r/Odoo
- 7 Migrating from Odoo.sh (Enterprise) to Self hosted (Community) — r/Odoo
- 8 Export and import data — Odoo 19.0 documentation
- 11 Odoo's annual major releases — r/Odoo (yearly breaking version cadence)
- 12 ORM reference — Odoo 19.0 documentation
- 13 Get started — Odoo 19.0 Accounting documentation (Chart of Accounts setup)
- 14 Managing Parent-Child Relationships Between Partners in Odoo — Odoo Forum
- 17 Export and import data — Odoo 14.0 documentation (Country/Database ID/External ID resolution)
- 18 Definitive foolproof steps for a successful import — Odoo Forum (Many2many comma-separated)
- 19 Upload size limit in Odoo — Odoo Forum (64 MiB Odoo.sh, 128 MiB self-hosted)
- 20 Fields and widgets — Odoo 19.0 documentation (Studio)
- 21 what is ondelete='cascade' in OpenERP — Odoo Forum
- 22 Batch Import — Odoo Forum (Odoo Online throttling)
- 27 Chart of Accounts and localisation — Odoo 19.0 Accounting documentation
- 30 What Does Odoo Migration Really Cost? Transparent Pricing Breakdown — Aspire Softserv
- 33 How to bulk insert record in Odoo — Odoo Forum (Model.create with list)
- 35 Error when importing Journal Entry — r/Odoo (unbalanced debits/credits)
- 36 Units of measure — Odoo 19.0 Inventory documentation
- 37 Units of measure and BOM in Odoo version 18.2 — Odoo Forum (tracking change blocked)
- 39 Manufacturing > Bill of Materials in Odoo — Odoo Forum (BOM structure)
- 41 Record exchange rates at payments — Odoo Accounting multi-currency documentation
- 42 Image file types supported in Odoo — Odoo Forum
- 43 Base Attachment Object Store (fs_attachment) — Odoo Apps Store
- 44 Import file and retain file date — Odoo Forum (create_date is upload time)
- 45 OCA Audit Log — The Odoo Community Association
- 46 Cannot import date field — Odoo Forum
- 47 Error when importing sample data — Odoo Forum (ondelete restrict)
- 52 Resellers and Implementation Partners — Odoo
Need help running this migration?
FlitStack AI runs Odoo ERP migrations end-to-end.
Fixed-fee pricing, a hands-on migration engineer, full field mapping and validation. The work described in this guide — done for you.