Business Application Migration
Business application migration transfers organisational data, configurations, and integrations from a source system to a target platform. This task covers migrations of CRM, ERP, human capital management, case management, and grants management systems. Execute this procedure when replacing a business application with a new platform, consolidating multiple systems into one, or moving from on-premises to cloud deployment.
The migration produces a fully operational target system containing all historical data, configured integrations, and trained users. Source systems remain available during parallel running before final decommissioning.
Prerequisites
Complete these requirements before beginning migration work. Missing prerequisites cause delays during critical cutover periods when recovery options are limited.
Target system readiness
| Requirement | Specification | Verification |
|---|---|---|
| Target platform provisioned | Production and UAT environments deployed | Login to both environments succeeds |
| Administrator access | Full admin rights on target system | Can create users, modify settings |
| Integration credentials | API keys, service accounts for all integrations | Authentication test passes for each |
| Storage capacity | 3x current data volume for migration workspace | Capacity report shows headroom |
| Licence allocation | Licences for all migrating users plus 10% buffer | Licence count confirmed with vendor |
Source system access
| Requirement | Specification | Verification |
|---|---|---|
| Export permissions | Rights to extract all data including attachments | Test export of sample records succeeds |
| Schema documentation | Complete field definitions for all objects | Documentation matches actual schema |
| Integration inventory | List of all systems connected to source | Each integration owner identified |
| Historical data scope | Decision on how many years of data to migrate | Documented and approved by data owner |
Data mapping completion
The data mapping document defines how each source field transforms into target fields. Complete mappings for all object types before extraction begins. A typical CRM migration involves 40 to 80 field mappings per object type; ERP migrations involve 200 to 500 mappings across financial objects.
Example mapping excerpt for contact records:
| Source field | Source type | Target field | Target type | Transformation |
|---|---|---|---|---|
CONTACT_ID | VARCHAR(20) | external_id | Text(50) | Prefix with ‘LEG-‘ |
FIRST_NM | VARCHAR(50) | first_name | Text(100) | Trim whitespace |
LAST_NM | VARCHAR(50) | last_name | Text(100) | Trim whitespace |
EMAIL_ADDR | VARCHAR(100) | email | Validate format, null if invalid | |
PHONE_1 | VARCHAR(20) | phone | Phone | Normalise to E.164 |
CREATED_DT | DATE | legacy_created | DateTime | Append ‘T00:00:00Z’ |
STATUS_CD | CHAR(1) | status | Picklist | Map: A→Active, I→Inactive, D→Deleted |
Team and schedule
| Role | Responsibility | Time commitment |
|---|---|---|
| Migration lead | Overall coordination, issue resolution | 100% during migration |
| Source system admin | Extraction, source queries | 50% during extraction, on-call during cutover |
| Target system admin | Loading, configuration | 50% during load phases, 100% during cutover |
| Integration developer | API work, connection testing | 25% ongoing, 100% during integration cutover |
| Data steward per object | Validation, exception handling | 20% during validation phases |
| Training coordinator | User preparation, support | 50% in final two weeks |
Reserve a cutover weekend with change approval at least four weeks in advance. Standard cutover windows span Friday 18:00 to Monday 06:00, providing 60 hours for execution and rollback if needed.
Procedure
The migration follows five phases: extraction, transformation, loading, validation, and cutover. Each phase has defined entry and exit criteria. Do not proceed to the next phase until exit criteria are met.
+----------------------------------------------------------------+| MIGRATION PHASES |+----------------------------------------------------------------+| || +------------+ +------------+ +------------+ || | | | | | | || | EXTRACTION +--->+ TRANSFORM +--->+ LOAD | || | | | | | | || | Week 1-2 | | Week 2-3 | | Week 3-4 | || +------------+ +------------+ +------+-----+ || | || +-------------------------------------+ || | || v || +------------+ +------------+ || | | | | || | VALIDATION +--->+ CUTOVER | || | | | | || | Week 4-5 | | Weekend | || +------------+ +------------+ || |+----------------------------------------------------------------+Phase 1: Data extraction
- Create extraction workspace on a system with network access to the source database and sufficient storage. For a source system containing 500,000 records with attachments, allocate 100 GB minimum.
mkdir -p /data/migration/{extracts,transforms,logs} df -h /data/migration # Confirm available space exceeds 100 GB- Extract reference data first. Reference data includes picklist values, status codes, user lists, and configuration tables that other records depend on. Export to CSV with UTF-8 encoding and Unix line endings.
-- Example: Extract status codes from source SELECT status_code, status_name, is_active, sort_order FROM ref_status_codes WHERE object_type = 'CONTACT' ORDER BY sort_order;Save output to /data/migration/extracts/ref_status_codes.csv. Record row counts in the extraction log.
Extract master data objects in dependency order. Master data includes entities that exist independently: organisations, contacts, users, products, accounts. Extract parent objects before children.
Typical dependency order for CRM:
- Organisations (no dependencies)
- Contacts (depends on organisations)
- Opportunities (depends on organisations, contacts)
- Activities (depends on opportunities, contacts)
For each object, execute a full extract with all fields:
SELECT * FROM contacts WHERE created_date >= '2019-01-01' -- Per historical scope decision ORDER BY contact_id;Extract transactional data after master data. Transactional data includes records created through business processes: invoices, payments, case notes, grant transactions. These have complex dependencies on master data.
For ERP financial data, extract in fiscal period order:
SELECT * FROM gl_transactions WHERE fiscal_year >= 2020 ORDER BY fiscal_year, fiscal_period, transaction_date;- Extract attachments and documents separately. Large binary objects slow extraction and require different handling. Create a manifest linking attachments to parent records.
# Generate attachment manifest psql -c "SELECT attachment_id, parent_type, parent_id, filename, file_size, mime_type FROM attachments" > /data/migration/extracts/attachment_manifest.csv
# Export binary files pg_dump --data-only --table=attachment_content \ --file=/data/migration/extracts/attachments.dump- Validate extraction completeness. Compare extracted row counts against source system counts. Discrepancies over 0.1% require investigation before proceeding.
-- Source system counts SELECT 'contacts' as object, COUNT(*) as cnt FROM contacts WHERE created_date >= '2019-01-01' UNION ALL SELECT 'organisations', COUNT(*) FROM organisations WHERE created_date >= '2019-01-01';Document counts in extraction log with timestamp and extractor name.
Phase 1 exit criteria: All objects extracted, row counts documented and verified within 0.1% of source counts, extraction log complete.
Phase 2: Data transformation
Transformation converts extracted data into the target system’s format. This phase executes the data mapping document and handles data quality issues.
- Set up the transformation environment. Use a scripting language with strong data handling capabilities. Python with pandas handles most transformations; complex ERP migrations benefit from dedicated ETL tools.
import pandas as pd from pathlib import Path
EXTRACT_DIR = Path('/data/migration/extracts') TRANSFORM_DIR = Path('/data/migration/transforms')
# Load reference data first status_map = pd.read_csv(EXTRACT_DIR / 'ref_status_codes.csv') status_lookup = dict(zip(status_map['status_code'], status_map['status_name']))Apply field-level transformations according to the mapping document. Handle each transformation type systematically:
Direct copy transfers values unchanged. Use for fields with matching types and no business logic differences.
Type conversion changes data types. Convert dates to ISO 8601, normalise phone numbers to E.164, validate email formats.
def transform_contacts(df): result = pd.DataFrame()
# Direct copy with rename result['external_id'] = 'LEG-' + df['CONTACT_ID'].astype(str)
# Trim whitespace result['first_name'] = df['FIRST_NM'].str.strip() result['last_name'] = df['LAST_NM'].str.strip()
# Validate and transform email email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' result['email'] = df['EMAIL_ADDR'].where( df['EMAIL_ADDR'].str.match(email_pattern), other=None )
# Normalise phone to E.164 result['phone'] = df['PHONE_1'].apply(normalise_phone)
# Map status codes result['status'] = df['STATUS_CD'].map( {'A': 'Active', 'I': 'Inactive', 'D': 'Deleted'} )
return resultValue mapping translates source values to target equivalents. Picklists, status codes, and categorical fields require explicit mapping tables.
Derived fields calculate new values from source data. Common derivations include full names from components, fiscal periods from dates, and categorisations from business rules.
- Handle data quality exceptions. Transformation reveals data quality issues hidden in the source system. Create exception reports rather than failing the entire transformation.
def transform_with_exceptions(df, transform_func, object_name): exceptions = [] results = []
for idx, row in df.iterrows(): try: results.append(transform_func(row)) except Exception as e: exceptions.append({ 'object': object_name, 'source_id': row.get('id', idx), 'error': str(e), 'row_data': row.to_dict() })
# Write exceptions for review if exceptions: pd.DataFrame(exceptions).to_csv( TRANSFORM_DIR / f'{object_name}_exceptions.csv', index=False )
return pd.DataFrame(results)Review exceptions with data stewards. Classify each exception as:
- Fix in source: Correct the data in source system, re-extract
- Fix in transformation: Add handling logic for edge case
- Exclude: Do not migrate this record (document reason)
- Manual load: Handle outside automated process
- Generate transformation audit trail. For each record, capture source ID, target ID, transformation timestamp, and any applied rules. The audit trail enables tracing discrepancies post-migration.
audit_records = []
for source_row, target_row in zip(source_df.itertuples(), target_df.itertuples()): audit_records.append({ 'source_id': source_row.CONTACT_ID, 'target_external_id': target_row.external_id, 'transform_timestamp': datetime.utcnow().isoformat(), 'rules_applied': 'standard_contact_transform_v2' })
pd.DataFrame(audit_records).to_csv( TRANSFORM_DIR / 'contacts_audit.csv', index=False )- Validate transformed data against target schema. Before loading, verify all required fields are populated, field lengths fit target constraints, and foreign key relationships are satisfiable.
def validate_for_target(df, schema): errors = []
for field, spec in schema.items(): if spec['required'] and df[field].isna().any(): errors.append(f"{field}: {df[field].isna().sum()} null values")
if spec['max_length']: over_length = df[field].str.len() > spec['max_length'] if over_length.any(): errors.append( f"{field}: {over_length.sum()} values exceed " f"{spec['max_length']} chars" )
return errorsPhase 2 exit criteria: All objects transformed, exception rate below 2%, exceptions reviewed and dispositioned, audit trail complete, schema validation passes.
Phase 3: Data loading
Loading imports transformed data into the target system. Execute loads in UAT first, then production after validation passes.
- Prepare the target environment for bulk loading. Disable automated workflows, email notifications, and validation rules that would fire during import. Document everything disabled for re-enablement after loading.
Disabled for migration load: - Workflow: New Contact Welcome Email (disabled 2024-03-15 09:00) - Workflow: Opportunity Stage Change Notification (disabled 2024-03-15 09:00) - Validation: Email Domain Check (disabled 2024-03-15 09:05) - Trigger: Contact Duplicate Check (disabled 2024-03-15 09:05)- Load reference data first. Reference data must exist before dependent records can reference it. Use the target system’s import API or bulk loader.
# Example: Load status values via API curl -X POST https://target-system.example.org/api/v2/picklists/status \ -H "Authorization: Bearer ${API_TOKEN}" \ -H "Content-Type: application/json" \ -d @/data/migration/transforms/ref_status_codes.json- Load master data in dependency order. Parent objects before children. Use batch operations to improve throughput; typical batch sizes range from 200 to 2,000 records depending on record complexity and API limits.
import requests from time import sleep
def load_batch(records, endpoint, batch_size=500): results = {'success': 0, 'failed': 0, 'errors': []}
for i in range(0, len(records), batch_size): batch = records[i:i + batch_size]
response = requests.post( f'https://target-system.example.org/api/v2/{endpoint}/batch', headers={'Authorization': f'Bearer {API_TOKEN}'}, json={'records': batch} )
if response.status_code == 200: batch_result = response.json() results['success'] += batch_result['created'] results['failed'] += len(batch_result.get('errors', [])) results['errors'].extend(batch_result.get('errors', [])) else: results['failed'] += len(batch) results['errors'].append({ 'batch_start': i, 'error': response.text })
# Respect rate limits sleep(1)
return results- Capture ID mappings during load. The target system generates new internal IDs. Map these back to source IDs for relationship linking and post-migration tracing.
id_mapping = {}
for source_id, target_response in load_results: id_mapping[source_id] = target_response['id']
# Save mapping for relationship linking pd.DataFrame([ {'source_id': k, 'target_id': v} for k, v in id_mapping.items() ]).to_csv(TRANSFORM_DIR / 'contacts_id_mapping.csv', index=False)- Load child objects using ID mappings to resolve foreign keys. Replace source system foreign keys with target system IDs before loading.
# Load opportunities referencing contacts opportunities_df = pd.read_csv( TRANSFORM_DIR / 'opportunities_transformed.csv' )
# Map contact foreign keys contact_id_map = pd.read_csv( TRANSFORM_DIR / 'contacts_id_mapping.csv' ).set_index('source_id')['target_id'].to_dict()
opportunities_df['contact_id'] = ( opportunities_df['source_contact_id'].map(contact_id_map) )
# Handle unmapped references unmapped = opportunities_df['contact_id'].isna() if unmapped.any(): print(f"Warning: {unmapped.sum()} opportunities have unmapped contacts") # Log for manual review- Load attachments after parent records exist. Stream large files rather than loading entirely into memory.
def load_attachment(file_path, parent_type, parent_id): with open(file_path, 'rb') as f: response = requests.post( f'https://target-system.example.org/api/v2/{parent_type}/' f'{parent_id}/attachments', headers={'Authorization': f'Bearer {API_TOKEN}'}, files={'file': f} ) return response.status_code == 201- Re-enable disabled components after loading completes. Test each component functions correctly with migrated data before proceeding.
Phase 3 exit criteria: All records loaded to UAT, load error rate below 1%, ID mappings complete, disabled components re-enabled, basic functionality verified.
Phase 4: Validation
Validation confirms migrated data is complete, accurate, and usable. Execute validation in UAT before production cutover.
Perform record count reconciliation. Compare counts in target system against source system and transformation output.
Object Source count Transformed Loaded Variance Contacts 45,230 45,180 45,180 -0.11% Organisations 12,450 12,450 12,450 0.00% Opportunities 8,920 8,915 8,915 -0.06% Activities 234,500 234,420 234,418 -0.03% Investigate variances exceeding 0.1%. Common causes: date range filtering, duplicate removal, exception exclusions.
Execute field-level validation on a statistical sample. For objects with over 10,000 records, validate a random sample of 500 records. Compare field values between source and target.
import random
sample_ids = random.sample(list(contact_id_map.keys()), 500)
validation_results = [] for source_id in sample_ids: target_id = contact_id_map[source_id]
source_record = fetch_from_source(source_id) target_record = fetch_from_target(target_id)
for field_mapping in mapping_document: source_val = source_record.get(field_mapping['source_field']) target_val = target_record.get(field_mapping['target_field']) expected = apply_transform(source_val, field_mapping)
validation_results.append({ 'source_id': source_id, 'field': field_mapping['target_field'], 'expected': expected, 'actual': target_val, 'match': expected == target_val })
# Calculate pass rate pass_rate = sum(r['match'] for r in validation_results) / len(validation_results) print(f"Field validation pass rate: {pass_rate:.2%}")Target pass rate: 99.5% or higher. Lower rates indicate systematic transformation errors.
- Validate referential integrity. Confirm all foreign key relationships resolve correctly in the target system.
-- Find orphaned opportunity records SELECT o.id, o.external_id, o.contact_id FROM opportunities o LEFT JOIN contacts c ON o.contact_id = c.id WHERE c.id IS NULL; -- Expected: 0 rows- Verify calculated fields and aggregations. Business logic in the target system may calculate totals, statuses, or derived values. Compare against source system values.
-- Compare organisation opportunity totals SELECT o.external_id, o.opportunity_total as migrated_total, (SELECT SUM(amount) FROM opportunities WHERE organisation_id = o.id) as calculated_total FROM organisations o WHERE o.opportunity_total != ( SELECT SUM(amount) FROM opportunities WHERE organisation_id = o.id );Conduct user acceptance testing. Provide data stewards and key users access to UAT. Supply test scripts covering common workflows:
- Find a specific contact by name and verify details
- Open an organisation and verify related records
- Generate a report matching a known source system report
- Execute a search matching known criteria
Document test results with screenshots. Obtain sign-off from each data steward.
Validate integrations with test transactions. Each integration point requires end-to-end testing in UAT.
Integration test: Finance system sync
1. Create test transaction in target CRM: Invoice INV-TEST-001, £1,500.00 2. Trigger sync to finance system 3. Verify transaction appears in finance system within 5 minutes 4. Verify amount, date, reference match 5. Delete test transaction from both systems
Result: PASS / FAIL Tester: _______________ Date: _______________Phase 4 exit criteria: Count reconciliation within 0.1%, field validation above 99.5%, zero orphaned records, user acceptance sign-off obtained, integration tests pass.
Phase 5: Production cutover
Cutover migrates production data and transitions users to the target system. Execute during the reserved change window.
+---------------------------------------------------------------------+| CUTOVER SEQUENCE |+---------------------------------------------------------------------+| || Friday 18:00 Saturday Sunday Monday || | | | | || +----v----+ +----v----+ +----v----+ +----v----+ || | Source | | Final | | Go-Live | | Support | || | freeze | | load | | verify | | & hyper | || | | | | | | | care | || +---------+ +---------+ +---------+ +---------+ || || 18:00 Freeze 00:00 Delta 06:00 UAT 06:00 Users || 19:00 Delta 06:00 Complete 12:00 Prod access || extract 08:00 Validate 18:00 Go/NoGo production || |+---------------------------------------------------------------------+- Communicate cutover start to all stakeholders. Send notification 24 hours before and again at freeze time.
Subject: CRM Migration - System freeze starting Friday 18:00
The CRM system will be READ-ONLY from Friday 18:00 until Monday 06:00.
During this time: - You can view existing records - You cannot create or edit records - Email logging is paused (emails will be logged after migration)
On Monday morning, access the new system at: https://crm.example.org Training materials: https://intranet.example.org/crm-training
Support contact: it-support@example.org or ext. 2200- Freeze the source system for write operations. Configure read-only mode or revoke write permissions. Verify no writes occur after freeze time.
-- Verify no records modified after freeze SELECT COUNT(*) FROM contacts WHERE modified_date > '2024-03-22 18:00:00'; -- Expected: 0- Extract delta records created since the last full extraction. Use modification timestamps to identify changes.
SELECT * FROM contacts WHERE modified_date > '2024-03-15 00:00:00' -- Last full extract ORDER BY contact_id;Transform and load delta records using the same process as the full migration.
Execute final data synchronisation. Run incremental extraction, transformation, and loading for all changed records. Typical delta volumes are 5% to 15% of full dataset.
Perform production validation. Execute abbreviated validation suite on production data:
- Record count reconciliation (all objects)
- Sample validation (100 records per object type)
- Critical report comparison
- Integration connectivity test
Make go/no-go decision. At the scheduled decision point, assess readiness:
Criterion Threshold Actual Status Count variance < 0.1% 0.03% PASS Sample accuracy > 99.5% 99.8% PASS Critical integrations 100% 100% PASS Blocking defects 0 0 PASS Rollback tested Yes Yes PASS GO: All criteria met. Proceed to go-live. NO-GO: Any criterion failed. Execute rollback, schedule new window.
Enable user access to production. Update DNS, distribute credentials, or enable SSO integration. Remove access to source system write functions.
Monitor hypercare period. Provide enhanced support for 5 business days post-migration:
- Dedicated support queue for migration issues
- Data stewards available for exception review
- Daily defect triage with migration team
- Twice-daily status updates to stakeholders
Rollback window
Maintain rollback capability for 48 hours post-cutover. After 48 hours, new data in the target system makes rollback impractical without data loss. Communicate this constraint to decision-makers.
Post-migration activities
Complete these activities within 30 days of cutover:
Resolve exception backlog: Process all records flagged during migration. Target: zero open exceptions within 14 days.
Decommission source system: After confirming target system stability, archive source system data and decommission infrastructure. Follow System Decommissioning procedures.
Update documentation: Revise integration documentation, system diagrams, and operational runbooks to reflect the target system.
Conduct lessons learned: Document what worked, what failed, and recommendations for future migrations.
Verification
Confirm successful migration through these verification steps:
Record completeness verification:
-- Target system record countsSELECT 'contacts' as object, COUNT(*) as count FROM contactsUNION ALL SELECT 'organisations', COUNT(*) FROM organisationsUNION ALL SELECT 'opportunities', COUNT(*) FROM opportunities;Compare against documented source counts. Variance should not exceed 0.1% (excluding documented exclusions).
Data accuracy verification:
Select 50 records at random and manually compare source and target field values. All transformed fields should match expected values based on mapping rules. Document any discrepancies with explanation.
Integration verification:
| Integration | Test action | Expected result | Actual result |
|---|---|---|---|
| Finance sync | Create invoice | Appears in finance system within 5 min | |
| Email logging | Send test email | Logged against correct contact | |
| Marketing platform | Update contact | Syncs within 15 min | |
| Reporting warehouse | Run daily extract | Includes migrated records |
User functionality verification:
Have three users from different teams execute their standard workflows and confirm:
- Records display correctly
- Searches return expected results
- Reports generate accurate data
- Workflow triggers function
Troubleshooting
| Symptom | Cause | Resolution |
|---|---|---|
| Load fails with “duplicate external ID” | External ID already exists from previous load attempt | Clear failed records from target, implement idempotent load logic |
| Foreign key constraint violation during load | Child records loaded before parents | Reorder load sequence, verify dependency order |
| Character encoding errors in transformed data | Source encoding differs from UTF-8 | Specify source encoding in extraction, convert explicitly during transformation |
| API rate limit exceeded during load | Batch size too large or no delay between batches | Reduce batch size to 200, add 2-second delay between batches |
| Missing attachments in target system | Attachment load ran before parent records existed | Reload attachments after parent records confirmed, verify parent ID mapping |
| Date values shifted by one day | Timezone conversion issue | Standardise on UTC throughout migration, apply timezone only at display |
| Decimal precision loss in financial data | Float type used instead of decimal | Use decimal/numeric types, validate sum totals match |
| Lookup field shows “not found” | Reference data not loaded or ID mapping incorrect | Verify reference data exists, check ID mapping table |
| Users cannot find records by old ID | External ID search not configured | Add external ID to search index, train users on new search |
| Integration fails with authentication error | Service account credentials not updated for target system | Update integration credentials, verify OAuth tokens if applicable |
| Performance degradation in target system | Missing indexes, suboptimal queries from migration data patterns | Analyse slow queries, add indexes, rebuild statistics |
| Reports show different totals than source | Aggregation logic differs between systems, or data excluded during migration | Compare SQL logic, reconcile excluded records, adjust report definitions |
| Workflow firing on migrated records | Workflow evaluates on record creation, not migration flag | Add migration flag field, filter workflows to exclude migrated records |
| Users see records they should not access | Sharing rules not migrated or differ between systems | Review sharing model, manually configure record access, bulk update owner assignments |
| Historical dates display incorrectly | Date format interpretation differs | Verify ISO 8601 format used, check locale settings in target |