top of page

How to Validate a Salesforce Migration — Without Losing Your Customer Data

Writer: Vexdata
Vexdata
3 hours ago
13 min read


The go-live report said the migration was complete. All 847,000 records transferred. Zero errors in the migration log.


Three weeks later, the sales team noticed that roughly 12,000 opportunities had no associated account. The account records existed. The opportunity records existed. The relationship between them was gone.


This is what a successful Salesforce migration failure looks like. The data moves. The counts match. The logs are clean. And somewhere in the process, the relationships, the history, and the business logic that made that data useful got quietly broken.


Salesforce migrations are among the most complex data migrations a revenue team will undertake, because Salesforce is not a simple row-based database. It is a relational object model with lookup fields, master-detail relationships, formula fields that depend on other fields, process builder rules that fire on field changes, and a history tracking system that is separate from the records themselves. Moving records from one org to another — or from a legacy CRM into Salesforce — requires validating all of these layers, not just the row count.


This guide covers every validation check your team needs before and after a Salesforce migration:

  • What to validate before go-live (the checks that catch problems while they are still fixable)

  • Account and contact relationship integrity (the most common silent failure)

  • Opportunity and pipeline data (stage history, close date shifts, amount accuracy)

  • Custom field mapping (the place where assumptions about your data get exposed)

  • Duplicate detection after upsert (the new problem your migration may have created)

  • How to automate ongoing validation so post-migration drift gets caught early


All checks include the specific SOQL queries or validation approaches you need to run them.


Why Salesforce Migrations Fail Silently

Most migration tools are built to move data, not to validate meaning. They measure success by whether records landed, not by whether those records are correct.

This creates a specific class of failure: the migration completes without errors, the record counts match the source, and the data looks right in list views. But the data is wrong in ways that only surface when someone tries to use it.

Here is why this happens more often in Salesforce than in most other migration scenarios.


Salesforce uses relationships, not just records

A standard row-based database migration can verify correctness by comparing source and target tables. Salesforce objects are connected through lookup fields and master-detail relationships. An Account record is not just a row; it is a node in a graph that connects to Contacts, Opportunities, Cases, Activities, and custom objects. When you migrate records independently and then try to re-establish relationships using external IDs or matching rules, any mismatch in the matching logic produces orphaned records that are individually valid but relationally broken.


Upsert operations can create duplicates or overwrite the wrong record

Salesforce migrations typically use upsert, which inserts new records or updates existing ones based on a matching field. If the matching field (usually an external ID or email) is not perfectly clean in the source data, upsert creates duplicate records instead of updating existing ones, or updates the wrong record entirely. The operation exits successfully in both cases.


Field mapping assumptions are rarely verified

When migrating from a legacy CRM or a different Salesforce org, field values are mapped from source fields to target fields. The mapping seems obvious on paper: source.customer_tier maps to target.Account_Tier__c. But if the source used different picklist values (Enterprise, Mid-Market, SMB) than the target (Tier 1, Tier 2, Tier 3), every migrated record lands with a blank or invalid picklist value. No error. Just missing data.


History and audit data migrates differently from live data

Salesforce Field History Tracking and the Activity Timeline are separate from the object records. You cannot migrate historical field values directly into Field History Tracking. If your compliance requirements need a full audit trail, or if your team relies on opportunity stage history for forecasting, the migration may leave gaps that are not visible in the records themselves.


Formula fields and roll-up summaries recalculate on the new org's rules

Formula fields recalculate based on the current org's formula definition and the current field values. If the source org had a different formula, or if the target org's formula references a field that was not migrated correctly, formula field values will differ between source and target. Roll-up summary fields on master objects recalculate from the child records, which means they are only accurate if the child records migrated correctly.


What to Validate Before Go-Live

The cheapest time to find a migration problem is before the cutover. Once users are working in the new org, fixing data means explaining to the sales team why their pipeline history looks different, re-closing tickets that reference the wrong accounts, and rebuilding trust in a system that just visibly failed.


Run these checks in your staging org after a full migration load, before you flip the switch.


Record count comparison by object

The most basic check, and still necessary. For every object you migrated, compare source count to target count.

-- SOQL query on target org
SELECT COUNT(Id) FROM Account
SELECT COUNT(Id) FROM Contact
SELECT COUNT(Id) FROM Opportunity
SELECT COUNT(Id) FROM Lead
SELECT COUNT(Id) FROM Case

Compare each result against the source. Any discrepancy needs an explanation before go-live. Missing records are obvious; extra records (more in the target than the source) usually mean duplicates from upsert mismatches.


Relationship coverage check

For each lookup or master-detail relationship, verify that the percentage of populated lookups matches the source.

-- What percentage of Contacts have an Account?
SELECT
  COUNT(Id) AS total_contacts,
  COUNT(AccountId) AS contacts_with_account
FROM Contact

-- What percentage of Opportunities have an Account?
SELECT
  COUNT(Id) AS total_opps,
  COUNT(AccountId) AS opps_with_account
FROM Opportunity

If your source had 98% of Contacts linked to an Account and your target has 71%, something broke in the relationship migration.


Required and critical field population rates

For every field that is required in your business process (even if not system-required in Salesforce), check the null rate.

-- Check null rate on fields critical to your process
SELECT
  COUNT(Id) AS total,
  COUNT(Industry) AS has_industry,
  COUNT(BillingCountry) AS has_billing_country,
  COUNT(Type) AS has_type
FROM Account

Picklist value validity

After migrating, check that picklist values are valid in the target org. Invalid values often get cleared silently.

-- Find Opportunities with no Stage value (may indicate invalid picklist migration)
SELECT COUNT(Id)
FROM Opportunity
WHERE StageName = null OR StageName = ''

-- Find Accounts where Type is null (if it was populated in source)
SELECT COUNT(Id)
FROM Account
WHERE Type = null

Owner assignment check

Record ownership is migrated by matching source users to target users. If a source user does not exist in the target org (someone who left, or a different domain), their records get assigned to a default owner or fail.

-- Find records assigned to your migration default user
SELECT OwnerId, COUNT(Id)
FROM Account
GROUP BY OwnerId
ORDER BY COUNT(Id) DESC
LIMIT 10

If your default migration owner has an unusually high record count, it means user matching failed for those records.



Account and Contact Relationship Integrity Checks

The Account-Contact relationship is the backbone of most Salesforce implementations. Break it, and you break every report, every territory assignment, and every process that depends on knowing which contact belongs to which company.

This is also the most common place where Salesforce migrations fail silently.


Find Contacts With No Account

SELECT Id, FirstName, LastName, Email, CreatedDate
FROM Contact
WHERE AccountId = null
ORDER BY CreatedDate DESC

In B2B Salesforce orgs, every Contact should have an Account. If this query returns a significant number of records, your Account matching during migration broke for those contacts. The contacts exist. The accounts exist. The link between them does not.


Find Accounts With No Contacts

SELECT Id, Name, Industry, AnnualRevenue
FROM Account
WHERE Id NOT IN (SELECT AccountId FROM Contact WHERE AccountId != null)
  AND Type != 'Prospect'

Some accounts legitimately have no contacts (early-stage prospects, partner accounts). But if established customer accounts have zero contacts after migration, their contacts were either missed or failed to link.


Verify Contact-to-Account Ratio

This is a sanity check: compare the average contacts per account between source and target. A significant drop in the ratio suggests orphaned contacts.

-- Run this in both source and target and compare
SELECT
  COUNT(DISTINCT AccountId) AS accounts_with_contacts,
  COUNT(Id) AS total_contacts,
  COUNT(Id) / COUNT(DISTINCT AccountId) AS avg_contacts_per_account
FROM Contact
WHERE AccountId != null

Check Primary Contact on Accounts

If your org uses a custom Primary Contact field or a Contact Role, verify these migrated correctly.

-- Find Opportunities with no Primary Contact Role
SELECT Id, Name, AccountId, StageName, CloseDate
FROM Opportunity
WHERE Id NOT IN (
  SELECT OpportunityId
  FROM OpportunityContactRole
  WHERE IsPrimary = true
)
AND IsClosed = false

Open opportunities with no primary contact role will cause problems in any process that relies on knowing who to contact about a deal.


Validate Custom Lookup Relationships

For every custom lookup field you migrated (custom object relationships, junction objects, etc.), run a targeted orphan check:

-- Example: custom object linked to Account via a lookup field
SELECT Id, Name
FROM Custom_Object__c
WHERE Account__c = null

Substitute your actual object and field API names. Any non-zero result for a required lookup means the relationship migration failed for those records.



Opportunity and Pipeline Data: What Breaks and How to Catch It

Opportunity data is where migration errors become revenue errors. A broken account-contact link is an operational problem. A broken pipeline is a business problem. Sales leadership will notice, and they will notice fast.


Stage Distribution Check

After migration, the distribution of opportunities across stages should match the source. If your source had 23% of open pipeline in Proposal/Price Quote and your target has 4%, either records moved to the wrong stage or the stage migration failed for a large batch.

SELECT StageName, COUNT(Id) AS opp_count, SUM(Amount) AS total_amount
FROM Opportunity
WHERE IsClosed = false
GROUP BY StageName
ORDER BY COUNT(Id) DESC

Run this in source and target and compare the distributions. Stage names, counts, and total amounts should be proportionally consistent.


Close Date Accuracy

Close dates are frequently corrupted during migration when date format handling differs between systems, or when UTC conversion shifts dates by one day.

-- Find opportunities with close dates in the past that are still open
SELECT Id, Name, StageName, CloseDate, Amount
FROM Opportunity
WHERE CloseDate < TODAY
  AND IsClosed = false
ORDER BY CloseDate ASC
LIMIT 100

Some past-close open opportunities are legitimate (deals that slipped). But a large number after migration often means close dates shifted during the data move.


Amount Field Validation

-- Compare total pipeline value between source and target
SELECT
  COUNT(Id) AS open_opps,
  SUM(Amount) AS total_pipeline,
  AVG(Amount) AS avg_deal_size,
  MIN(Amount) AS min_amount,
  MAX(Amount) AS max_amount
FROM Opportunity
WHERE IsClosed = false

The total pipeline value should match the source within a small tolerance. A significant discrepancy in total amount or average deal size suggests amount field mapping issues.


Won/Lost Ratio Check

SELECT IsWon, COUNT(Id) AS count, SUM(Amount) AS total_value
FROM Opportunity
WHERE IsClosed = true
  AND CloseDate >= LAST_N_MONTHS:12
GROUP BY IsWon

Compare the won/lost ratio for the last 12 months between source and target. If it has changed significantly, historical opportunity status may have been migrated incorrectly.


Opportunity History Gap Check

Salesforce Opportunity History (the stage change timeline) cannot be directly migrated through the standard API. If your team relies on stage history for forecasting or coaching, verify what actually came across.

-- Check if Opportunity History records exist for migrated opportunities
SELECT OpportunityId, COUNT(Id) AS history_records
FROM OpportunityHistory
GROUP BY OpportunityId
HAVING COUNT(Id) = 1
LIMIT 100

An opportunity with only one history record means the only entry is the creation event. If your source deals had 8-10 stage changes over 6 months of active selling, one history record means the history was not migrated. This needs to be communicated to the team before go-live, not discovered by a sales manager six months later.


Custom Field Mapping Validation

Standard fields (Name, Email, Phone, BillingAddress) have known types and expected formats. Custom fields are where migration assumptions get tested.


Every custom field in your Salesforce org represents a decision someone made about how to store a piece of information specific to your business. When you migrate custom fields, you are also migrating those decisions, and the migration tool has no way to know what the data should look like.


Check Custom Field Population Rates

For every custom field that should be populated after migration, verify its null rate against the source.

-- Example: check population rate on key custom fields
SELECT
  COUNT(Id) AS total_accounts,
  COUNT(Customer_Tier__c) AS has_tier,
  COUNT(Contract_Value__c) AS has_contract_value,
  COUNT(Industry_Segment__c) AS has_industry_segment,
  COUNT(CSM_Owner__c) AS has_csm
FROM Account

Compare each count against the source. A field that was 94% populated in the source and is 12% populated in the target means either the mapping failed or the field values did not match the target's validation rules.


Validate Picklist Values on Custom Fields

Picklist fields are the most common source of custom field migration failures. Source values that do not exist in the target org's picklist definition get cleared silently during upsert.

-- Find the distribution of values in a custom picklist field
SELECT Customer_Tier__c, COUNT(Id) AS record_count
FROM Account
GROUP BY Customer_Tier__c
ORDER BY COUNT(Id) DESC

If you see a large number of null values in this result and the source had those fields populated, the picklist values did not match. The fix is to either add the source values to the target picklist definition before migration, or run a value transformation as part of the migration process.


Validate Custom Currency and Number Fields

Currency fields can be corrupted during migration if the source and target have different multi-currency settings, or if number formatting (decimal separator, thousands separator) differs between systems.

-- Check for suspicious values in currency custom fields
SELECT
  COUNT(Id) AS total,
  COUNT(Contract_Value__c) AS has_value,
  MIN(Contract_Value__c) AS min_value,
  MAX(Contract_Value__c) AS max_value,
  AVG(Contract_Value__c) AS avg_value
FROM Account
WHERE Contract_Value__c != null

Compare min, max, and average values against source. If your average contract value in the source was $45,000 and in the target it is $45, a decimal place was lost in the migration.


Formula Field Output Comparison

Formula fields recalculate automatically, so they cannot be migrated directly. After migration, recalculated values should match the source values if all the input fields migrated correctly. Run spot checks on critical formula fields.

-- Example: check a formula field that calculates days since last activity
SELECT Id, Name, Days_Since_Last_Activity__c, LastActivityDate
FROM Account
WHERE LastActivityDate != null
  AND Days_Since_Last_Activity__c > 365
ORDER BY Days_Since_Last_Activity__c DESC
LIMIT 20

If a formula field produces clearly wrong values (a "days since last activity" formula showing 10,000 days for accounts that were active last month), the input field it references did not migrate correctly.


Duplicate Detection After Upsert

Upsert is the standard mechanism for Salesforce data loads because it handles both new records and updates to existing ones in a single operation. The problem is that upsert's deduplication only works when the matching field value is exactly right.


If your matching field (usually an external ID or email address) has any inconsistency between source and target, upsert creates a new record instead of updating the existing one. You end up with two records for the same entity, and both are valid in isolation. The duplicate is invisible to any validation that only checks record counts.


Find Duplicate Accounts by Name and Domain

-- Find Account names that appear more than once
SELECT Name, COUNT(Id) AS count
FROM Account
GROUP BY Name
HAVING COUNT(Id) > 1
ORDER BY COUNT(Id) DESC
LIMIT 50

This catches exact name duplicates. For near-duplicates ("Acme Corp" vs "Acme Corporation"), you will need a fuzzy matching tool or Salesforce's native duplicate rules.


Find Duplicate Contacts by Email

-- Find email addresses that appear on more than one Contact
SELECT Email, COUNT(Id) AS count
FROM Contact
WHERE Email != null
GROUP BY Email
HAVING COUNT(Id) > 1
ORDER BY COUNT(Id) DESC
LIMIT 100

Duplicate contacts by email are the most common post-migration duplicate problem. The same person exists twice with different IDs, and any activity, email, or case created after migration will be attached to only one of them.


Find Duplicate Leads

SELECT Email, COUNT(Id) AS count
FROM Lead
WHERE Email != null
  AND IsConverted = false
GROUP BY Email
HAVING COUNT(Id) > 1
ORDER BY COUNT(Id) DESC
LIMIT 100

Cross-Object Duplicate Check (Lead and Contact)

If you migrated both Leads and Contacts from the same source, check for email addresses that appear in both objects, which may represent the same person in two different stages.

-- Find emails that exist in both Lead and Contact
SELECT Email
FROM Lead
WHERE Email IN (
  SELECT Email FROM Contact WHERE Email != null
)
  AND IsConverted = false
LIMIT 100

Enable Duplicate Rules Before Opening to Users

Before users start creating records in the new org, enable Salesforce's native Duplicate Rules on Account, Contact, and Lead. This prevents new duplicates from being created and surfaces existing ones. If you do not have duplicate rules configured, now is the time to set them up, before users start building on top of data that may still have duplicates in it.


How to Automate Ongoing Validation Post-Migration

Migration validation is not a one-time event. The checks above catch problems at cutover, but Salesforce data quality degrades continuously after go-live. Users create records incorrectly, integrations push malformed data, and automation rules occasionally fire in ways that corrupt existing records.


Building automated validation into your post-migration operations means catching these problems in days rather than months.


Scheduled SOQL Health Checks

The SOQL queries in this guide can be scheduled to run automatically using Salesforce-native tools (Scheduled Apex, Flow) or external orchestration tools (Airflow, dbt, a custom Python script using simple_salesforce).


A basic Python health check using simple_salesforce:

from simple_salesforce import Salesforce
from datetime import datetime

sf = Salesforce(
    username='your_username',
    password='your_password',
    security_token='your_token'
)

def run_health_checks(sf: Salesforce) -> dict:
    checks = {}

    # Orphaned contacts
    result = sf.query("SELECT COUNT(Id) cnt FROM Contact WHERE AccountId = null")
    checks['orphaned_contacts'] = result['records'][0]['cnt']

    # Open opps without account
    result = sf.query(
        "SELECT COUNT(Id) cnt FROM Opportunity WHERE AccountId = null AND IsClosed = false"
    )
    checks['opps_without_account'] = result['records'][0]['cnt']

    # Duplicate emails in Contact
    result = sf.query(
        "SELECT Email, COUNT(Id) cnt FROM Contact "
        "WHERE Email != null GROUP BY Email HAVING COUNT(Id) > 1"
    )
    checks['duplicate_contact_emails'] = result['totalSize']

    # Opportunities with no stage
    result = sf.query(
        "SELECT COUNT(Id) cnt FROM Opportunity "
        "WHERE StageName = null AND IsClosed = false"
    )
    checks['opps_missing_stage'] = result['records'][0]['cnt']

    return checks

def alert_on_failures(checks: dict, thresholds: dict) -> list:
    failures = []
    for check_name, value in checks.items():
        threshold = thresholds.get(check_name, 0)
        if value > threshold:
            failures.append({
                'check': check_name,
                'value': value,
                'threshold': threshold
            })
    return failures

Run this daily and alert your Salesforce admin when any check exceeds its threshold.


Key Metrics to Track Over Time

After migration, track these metrics weekly for the first three months:

  • Orphaned Contact count (should stay near zero)

  • Orphaned Opportunity count (should stay near zero)

  • Duplicate Contact email count (should trend toward zero as cleanup runs)

  • Null rate on required custom fields (should be stable or improving)

  • Open pipeline by stage distribution (should reflect actual sales activity, not migration artifacts)


Any metric that is trending in the wrong direction after go-live is a sign that an integration is pushing bad data, an automation rule is firing incorrectly, or a user workflow is creating records improperly.


Use Salesforce Data Quality Reports

Build native Salesforce reports for the most important checks and add them to a Data Quality dashboard visible to Salesforce admins. This makes the ongoing health of migrated data visible without requiring a technical tool to run queries.


Key reports to build:

  • Contacts without Accounts (filtered to records created in the last 30 days)

  • Open Opportunities without a primary Contact Role

  • Accounts without Industry or Type

  • Leads older than 90 days without an Activity


These do not require any external tooling and give your admin team visibility into data quality drift as it happens.


Treat Validation as Part of the Migration, Not an Afterthought

Salesforce migrations are measured by whether they complete. They should be measured by whether the data that lands is actually usable.

The checks in this guide are not optional cleanup steps for after go-live. They are the difference between a migration that your sales team trusts and one they spend the next quarter working around.


Run the pre-cutover checklist in your staging org. Fix relationship integrity issues before anyone logs in. Catch picklist mapping failures before the pipeline reports look wrong. Enable duplicate rules before users start creating records. And then keep running the health checks for 90 days after go-live, because migrations do not break all at once.


If you want to automate these validations across your Salesforce org and get alerts when data quality degrades, Vexdata runs continuous data validation on your pipelines and surfaces integrity issues before they reach your team. See how at the Salesforce migration validation page.

 
 
 

Comments


bottom of page