Data Migration
The most consistently underestimated activity in ERP implementation, and the most common cause of a delayed go-live. Budget 15–25% of total project effort for it.
The golden rule
You migrate master data and open items. You do not migrate history.
| Data | Migrate? |
|---|---|
| Active customers, vendors, materials, employees | ✅ Yes, cleansed |
| Chart of accounts, cost centres, org structure | ✅ Yes |
| BOMs, routings, price conditions | ✅ Yes |
| Open sales orders, purchase orders | ✅ Yes |
| Open AR and AP items | ✅ Yes |
| Current stock quantities and values | ✅ Yes |
| Fixed asset register with accumulated depreciation | ✅ Yes |
| GL opening balances as at cutover | ✅ Yes |
| Closed/posted invoices from prior years | ❌ No |
| Historical goods movements | ❌ No |
| Completed production orders | ❌ No |
| Ten years of journal entries | ❌ No |
Where history goes instead: the legacy system kept read-only for the statutory retention period, or extracted to a data warehouse / archive with a documented retrieval process.
Business users always ask for full history. The counter-argument: migrating posted documents means recreating the exact accounting entries, in the right periods, under a different chart of accounts and a different data model. It doesn’t reconcile, it takes months, and it delivers a system whose historical data can’t be trusted anyway. Give them a reporting archive instead.
The process
1. PROFILE What data exists? How much? How bad is it?
2. SCOPE What migrates, what doesn't — signed off
3. CLEANSE Fix it in the SOURCE system, with business owners
4. MAP Legacy field → new field, with transformation rules
5. EXTRACT Pull from legacy into a staging area
6. TRANSFORM Apply mappings, defaults, and derivations
7. VALIDATE Business rules, referential integrity, completeness
8. LOAD Into the target, via APIs or sanctioned load tools
9. RECONCILE Prove counts and values match
10. SIGN OFF Business owner accepts the loaded data
Repeat 5–9 at least four times: two test loads, a dress rehearsal, and the real cutover.
Data profiling — do this in week one
-- Volume
SELECT COUNT(*) FROM customers; -- 47,882
-- How many are actually active?
SELECT COUNT(*) FROM customers
WHERE id IN (SELECT DISTINCT customer_id FROM orders
WHERE order_date > CURRENT_DATE - 730); -- 6,104 ← the real number
-- Quality
SELECT COUNT(*) FROM customers WHERE tax_id IS NULL; -- 18,220
SELECT COUNT(*) FROM customers WHERE email NOT LIKE '%@%'; -- 3,447
SELECT COUNT(*) FROM customers WHERE country_code NOT IN
(SELECT code FROM valid_countries); -- 892
-- Duplicates
SELECT normalised_name, COUNT(*) FROM customers
GROUP BY normalised_name HAVING COUNT(*) > 1; -- 2,113 groups
Profiling almost always produces the same finding: the customer thinks they have 47,000 customers; they have 6,000 real ones and 41,000 records of accumulated debris. Migrating only the active ones cuts effort dramatically and improves the new system’s data quality on day one.
Present the profiling results to the business early. It’s the evidence that turns “migrate everything” into a rational scoping conversation.
Cleansing
Cleanse in the source system, not in the migration script.
Why: the business owns the data and must own the corrections; corrections made in a script are lost on the next extract; and the legacy system stays usable during the transition.
Cleansing activities:
- Deduplicate customers, vendors, materials
- Fill mandatory fields
- Standardise: country codes, UoM, addresses, phone formats
- Deactivate records with no activity in N years
- Correct invalid references
- Resolve inconsistent naming conventions
- Validate tax IDs, IBANs, postcodes
Assign a data owner per object. Not IT — the business. The Master Data Manager owns customers; Purchasing owns vendors; Engineering owns BOMs. Publish a weekly data quality scorecard from month one. Progress is visible and public, which is what makes it happen.
Mapping
Object: Customer
Legacy: CUSTMAST Target: business_partner + roles
Legacy field Type Target field Transformation
─────────────────────────────────────────────────────────────────────
CUST_NO CHAR(8) external_id Keep for cross-reference
partner_id New internal number range
CUST_NAME CHAR(40) name1 Trim, title case
CUST_NAME2 CHAR(40) name2 Trim, may be null
ADDR1..ADDR4 CHAR(40) street, street2, city Parse — see rule DM-C-014
ZIP CHAR(10) postal_code Validate against country format
CTRY CHAR(3) country_code Map: legacy 3-char → ISO 3166-1 alpha-2
Mapping table DM-MAP-COUNTRY
CURR CHAR(3) currency_code Direct; validate against ISO 4217
TERMS CHAR(2) payment_term_code Mapping table DM-MAP-TERMS (14 values)
CREDIT_LIM NUM(11,2) credit_limit If null → 0; if > 99,999,999 → flag
SLSMAN CHAR(3) sales_rep_id Map via employee migration; if not
found → default 'UNASSIGNED' + flag
TAX_ID CHAR(20) tax_registration Strip spaces/hyphens; validate by country
(none) — account_group Derive: if CTRY='BD' → 'DOMS' else 'EXPT'
(none) — reconciliation_account Constant '113000'
STATUS CHAR(1) blocked_flag 'A'→false, 'I'→true, 'D'→exclude entirely
Every field needs an explicit rule, including the ones that come from nowhere. The “(none)” rows — derived and constant values — are where most migration defects hide, because nobody reviews a field that has no source.
The staging area
staging_customer_raw exact copy of the extract, untouched
staging_customer_transformed after mapping
staging_customer_validated with validation results per record
staging_customer_load load-ready
migration_error record key, rule violated, severity, message
migration_mapping_country lookup tables, versioned
migration_run run id, timestamp, counts, status
Keep the raw extract untouched. When a number is wrong at reconciliation, you need to trace from the loaded record back through transformation to the original source value. Without the raw layer that trace is guesswork.
Validation layers
1. Technical Types, lengths, formats, encoding
2. Referential Every foreign key resolves in the target
3. Business Mandatory fields, valid combinations, value ranges
4. Duplicate No duplicates within the load or against existing data
5. Cross-object A sales order references a customer that will exist
6. Reconciliation Counts and control totals
Validate before loading, not during. A load that fails at record 40,000 of 60,000 leaves partially migrated data and a difficult recovery. Validate the whole file first, fix, then load clean.
Loading
| Method | Notes |
|---|---|
| Vendor load tools (SAP LSMW/LTMC/Migration Cockpit, D365 DMF, NetSuite CSV import, Odoo import) | Preferred. Uses standard validation and business logic |
| APIs / BAPIs | Full business logic; slower; good for complex objects |
| Direct table insert | Almost never. Skips validation, derived fields, and dependent records. It will corrupt your data |
| Recording/scripting the UI | Slow, brittle, but occasionally the only route |
Load order matters — dependencies are strict:
1. Org structure and configuration
2. Chart of accounts, cost centres, profit centres
3. Currencies and exchange rates
4. Business partners (customers, vendors)
5. Materials (general → plant → sales → valuation views)
6. Prices and condition records
7. BOMs, then routings, then production versions
8. Fixed assets (master, then values)
9. Open purchase orders
10. Open sales orders
11. Stock quantities and values
12. Open AR / AP items
13. GL opening balances (the balancing figure)
14. Employees, then time and payroll data
Get this order wrong and loads fail on missing references. The GL opening balance load comes last because it must reconcile against everything already loaded.
Opening balances
The most scrutinised part of the migration.
Legacy trial balance at cutover:
Assets 12,480,000
Liabilities (7,220,000)
Equity (5,260,000)
──────────────────────
Balance 0
Loaded via:
AR subledger open items → must equal legacy AR control account
AP subledger open items → must equal legacy AP control account
Inventory (qty × value) → must equal legacy inventory accounts
Fixed assets (APC + acc.dep) → must equal legacy asset accounts
All other GL accounts → loaded directly as balances
Then: new trial balance must equal legacy trial balance, exactly.
Exactly. Not “within rounding”. A difference of 3.47 means something is wrong, and it will grow. Finance will not sign off, and they are right not to.
The standard technique: load subledger detail (which produces GL postings automatically via reconciliation accounts), then load remaining GL balances against a data migration clearing account, which must end at zero. A non-zero clearing account balance is your error, quantified.
Reconciliation
Every object needs a reconciliation report:
Object: Customers
Source records 47,882
Excluded (inactive) (41,778) ← per agreed scope rule
Excluded (duplicates merged) (231)
In scope 5,873
Loaded successfully 5,847
Failed 26 ← listed with reasons
Reconciliation FAIL — 26 records require action
Object: Open AR
Source open items 3,412
Source total value 14,882,140.55
Loaded items 3,412
Loaded total value 14,882,140.55
Difference 0.00
Reconciliation PASS
Value reconciliation matters more than count reconciliation. Loading 3,412 records with the wrong values passes a count check and fails an audit.
Test loads
| Load | Purpose | Data |
|---|---|---|
| Load 1 | Prove the technical process works | Small subset, dirty |
| Load 2 | Volume and quality assessment | Full extract, partially cleansed |
| Load 3 | Full dress rehearsal, timed | Full, cleansed |
| Load 4 | Cutover rehearsal, exact procedure | Full, final |
| Production | The real one | Final extract at freeze |
Time every load. If the full load takes 26 hours and your cutover window is 18, you find out in rehearsal, not on go-live weekend. This is precisely what rehearsals are for.
Delta handling
Between the final test extract and cutover, the legacy system keeps running:
Option A — Freeze
Stop transactions in legacy, extract, load, go live
✅ Clean. ❌ Business stops for the window
Option B — Delta load
Full load beforehand, then load only changes during the window
✅ Shorter freeze. ❌ Complex, needs reliable change tracking
Option C — Manual re-entry
Freeze early; users re-enter the few transactions from the gap
✅ Simple. ❌ Only viable for low volumes
Most projects use A for master data (frozen a week early) and A or C for transactions.
Common failure modes
| Failure | Prevention |
|---|---|
| Cleansing started too late | Start in month one, with named business owners |
| History migration attempted | Scope it out early, with the archive alternative agreed |
| Load takes longer than the window | Time it in rehearsal; parallelise |
| Opening balances don’t reconcile | Clearing account technique; reconcile every test load |
| Duplicates created | Deduplicate in source; unique constraints in target |
| Encoding corrupts non-Latin names | UTF-8 end to end; test with real Bengali/Arabic/Chinese data |
| Numbers truncated or reformatted | Treat identifiers as strings throughout; never open a CSV in Excel |
| Dates in the wrong format | ISO 8601 in every intermediate file |
| Missing derived fields | Review the “(none)” rows in the mapping specifically |
| No rollback plan | Take a database backup before the production load |
Developer checklist
- Raw extract preserved untouched
- Every field has an explicit mapping rule, including derived and constant values
- Mapping tables are data, versioned, and business-reviewed
- Validation runs before load, not during
- Loads use vendor tools or APIs, never direct table inserts
- Load order respects dependencies
- Loads are restartable and idempotent
- Every object has a count and value reconciliation report
- Reconciliation is signed off by the business owner, per object
- Timed in rehearsal against production volume
- Rollback plan and backup taken before the production load
- Cross-reference table (legacy ID ↔ new ID) retained permanently
That last one matters more than people expect. For years afterwards someone will ask “what happened to customer 00048821 from the old system?” The cross-reference table answers it in seconds. Keep it forever.