← All articles

GTM Engineering

The Big-Bang Cutover Loses 8% of Your Records. Parallel-Run Instead.

The dedup rule you switch on to keep the data clean is the same rule that silently drops 8% of your records on insert. Here is the six-stage runbook: map, dedupe, dry-run, parallel, cutover, verify.

· 14 min read

Migrate one weekend and 8% of your contacts never arrive. Nobody wrote a bug. The dedup rule you switched on to keep the data clean did its job: it rejected every insert that matched an existing record, wrote the losers to an error file, and let the job report success. Wednesday the sales team notices deals with no history, a created date that reads last Saturday on every record, and 4,000 contacts that are simply gone. The load “succeeded” and the data is missing anyway.

For a decade the CRM migration meant a big-bang cutover. Map the objects Friday, load Saturday, flip the switch Monday, discover the damage Wednesday. That pattern loses data because it concentrates every unknown into one weekend with no clean state to fall back to. The object mapping, the part everyone scopes, is the easy 20%. The 80% that breaks cutovers is dedup behavior, field-history loss, and the absence of a rollback. This is the runbook for that 80%, and it is built as six stages you climb in order, not one weekend you survive.

8%
Records a block-mode dedup rule rejects to a silent error file on insert
6 objects
HubSpot's 4-object model maps to 6 Salesforce objects, not 1-to-1
2 cycles
Clean parallel-run reconciliations before a safe cutover

Here is the runbook I climb on every migration I own. Six stages, bottom to top, each one closing a specific way the big-bang loses data. Scroll it, then I will show you the crosswalk, the reject file, and the reconciliation query under every rung.

Climb it bottom to topThe migration runbook nobody writes down
  1. S6Verify, then keep the rollback until you retire HubSpotrollback held

    Post-cutover, re-run the reconciliation and hold HubSpot untouched. A bad migration stays a "we stay on HubSpot two more weeks" conversation instead of a data-loss incident with no undo.

  2. S5Cut over only when the reports tie and stay tiedtie 2x

    The calendar does not decide readiness. The reject count and the reconciliation delta do. Two clean cycles, not one, then the cutover date falls out of the process instead of driving it.

  3. S4Run both systems in parallel2 cycles

    HubSpot stays the source of truth while Salesforce shadows it. Reps can see Salesforce, but the number that counts still comes from the old system until the reconciliation report ties two cycles running.

  4. S3Dry-run the full volume into a sandbox0 unexplained

    Load every record into a sandbox with real dedup rules and real audit fields via Set Audit Fields. The count of rejected rows in the error file is the count the big-bang would have lost. Drive it to zero-unexplained.

  5. S2Set dedup to allow-and-report, not block8% at risk

    A block-mode duplicate rule rejects matching inserts to an error file that a successful Bulk API job produces alongside the wins. Switch it to allow-and-report so nothing drops silently, then reconcile matches on purpose.

  6. S1Map every object and every stage by hand4 to 6

    HubSpot Contacts, Companies, Deals, and Tickets fan out to six Salesforce objects. Write a crosswalk with one row per source value and an explicit transformation rule, so a second person can audit it before a loader runs.

Locate yourself on that ladder. Most teams jump straight from S1 to a cutover date and discover S2 through S6 as incidents. Every rung above the first subtracts a specific failure the weekend version cannot see coming.

S1: Map is where you decide what breaks later

HubSpot’s model is Contacts, Companies, Deals, and Tickets. Salesforce is Leads, Contacts, Accounts, Opportunities, Cases, and the Lead-to-Contact conversion has no clean HubSpot equivalent, which is how four objects become six. The first real decision: do HubSpot Contacts land as Leads or as Contacts under Accounts? Dump everything into Contacts and you lose the Lead lifecycle and create orphan contacts with no account. Split them and you need a rule for which HubSpot contacts are not yet qualified.

HubSpot Company     -> Account         (dedup key: normalized domain)
HubSpot Contact     -> Lead OR Contact (rule: associated to a Deal? Contact. Else Lead.)
HubSpot Deal        -> Opportunity     (map dealstage -> StageName explicitly, every stage)
HubSpot Ticket      -> Case

Map every pipeline stage by hand. HubSpot dealstages and Salesforce StageName do not line up one to one, and an unmapped stage lands as a blank or a default, which corrupts your forecast on day one. Here is the crosswalk I build before touching a loader, one row per source value, with the transformation rule written down so a second person can audit it.

HubSpot object.fieldSalesforce object.fieldTransformation rule
company.domainAccount.WebsiteNormalize: strip www, lowercase, use as dedup key
contact.emailLead.Email / Contact.EmailRoute by deal association
deal.dealstageOpportunity.StageNameExplicit per-stage map, no default
deal.amountOpportunity.AmountCurrency scale check, no implicit rounding
deal.createdateOpportunity.CreatedDateRequires Set Audit Fields permission
ticket.subjectCase.SubjectDirect

The load runs in dependency order or the children orphan. Accounts resolve first because Contacts and Opportunities both point at them.

Load order Load parents before children, always in dependency order
Accountsdomain keyContactsattach to AccountOpportunities+ stage history as dataverify attach rate before next object
An Opportunity loaded before its Account either fails on a required lookup or orphans. Resolve the domain-keyed Accounts first, verify, then attach downstream.

S2: The dedup rule that eats your data

This is the one that gets everyone. Salesforce duplicate rules can block or allow-with-alert on insert. Set to block, the Bulk API does not merge and it does not warn loudly. It rejects the row and writes it to an error file that a “successful” job produces alongside the successes (Salesforce Bulk API documentation). Load 50,000 contacts where 4,000 match an existing record and you have 46,000 in the org and 4,000 in a CSV nobody opened. That is the 8% on the stat tile above, and it is invisible until a rep goes looking for a contact that should be there.

Two rules survive dedup. First, load parents before children in dependency order, Accounts before Contacts before Opportunities, so no child orphans on a missing lookup. Second, set duplicate rules to allow-and-report during the load so nothing drops silently, then reconcile matches deliberately after. You want the merge to be a decision you make with the reject file in front of you, not a default the loader picked at 2am with a log you will never read.

S3: Field history does not migrate, so capture it as data

Salesforce field history and HubSpot’s timeline are not portable. You cannot load “this field changed on this date by this person” into the standard history tables. If your reps rely on knowing when a deal moved to Proposal, that history is born the day you load, and every record claims the load date as its origin.

Two mitigations. Load the real CreatedDate using the Set Audit Fields permission so records do not all claim to be born on migration Saturday. That permission is insert-only and one-shot per record (Salesforce Set Audit Fields documentation), so get it right the first time. And land the change history that matters as data, not as history: a Legacy_Stage_History__c long-text or a related custom object populated from HubSpot’s timeline export, so the story survives even though the native history does not.

The one-shot nature of Set Audit Fields catches teams twice. The org preference lets you pass CreatedDate and CreatedById on insert, but only on insert, and only while the permission is active. Update the record later and you cannot fix a wrong birth date without deleting and reloading, which cascades into every child that pointed at it. So the sequence is: enable the preference, load with real audit fields verified against a sample, then disable the preference so nobody backdates a record after go-live. Treat the audit-field window as a controlled, brief phase of the load, not a setting you leave on.

Decide before the load which history matters to the business, because you cannot preserve all of it and pretending you can is how the reconciliation balloons. Reps rarely need every field change. They need two things: when the deal entered each pipeline stage, and who owned it when. Land those two as data and let the rest go.

// Enable "Set Audit Fields upon Record Creation" (Setup > User Interface)
// then the loader can pass CreatedDate. Otherwise every record is born on load day.
Opportunity o = new Opportunity(
    Name = 'Acme - Platform',
    StageName = 'Closed Won',
    CloseDate = Date.newInstance(2025, 3, 30),
    CreatedDate = Datetime.newInstance(2024, 11, 2, 9, 0, 0), // real HubSpot createdate
    Legacy_Stage_History__c = 'Discovery 2024-11-02; Proposal 2025-01-15; Won 2025-03-30'
);

S4 and S5: Dry-run into a sandbox, then reconcile to zero

Before any cutover date exists, load the full volume into a sandbox and stand up a reconciliation report whose counts must match the live HubSpot numbers, or the delta must be explained. This is the report that tells you whether you are ready, and it runs against the loaded sandbox.

-- Reconciliation cut: run against the loaded sandbox, compare each line to HubSpot
SELECT 'Accounts' obj, COUNT(Id) cnt, NULL amt FROM Account
UNION ALL
SELECT 'Open Opps', COUNT(Id), SUM(Amount) FROM Opportunity WHERE IsClosed = false
UNION ALL
SELECT 'Contacts w/o Account', COUNT(Id), NULL FROM Contact WHERE AccountId = null

The dry-run is also where you meet the throughput limits before they meet you. Salesforce caps daily API calls at 100,000 plus 1,000 per license (Salesforce developer limits), and HubSpot’s CRM Search API allows four requests per second (HubSpot API documentation), so a naive record-by-record export against HubSpot throttles long before your Salesforce load does. Batch the read, respect the rate limit, and size the job against the smaller of the two ceilings. Here is the readiness gate I hold before a cutover date is allowed to exist.

Readiness checkSource of truthPass condition
Reject file countBulk API error CSVZero, or every reject named
Account record countHubSpot CompaniesMatches within explained delta
Open pipeline sumHubSpot open dealsAmount ties to the dollar
Contacts with no AccountSalesforce SOQLZero orphans, or each explained
CreatedDate spot-checkHubSpot createdateSample matches, no load-day births
Stage map coverageCrosswalk tableEvery dealstage maps, no defaults

S4: Parallel-run, never big-bang

The weekend cutover is the failure pattern. You cannot validate 50,000 records and a forecast in a weekend, and when something is wrong you have no clean state to fall back to. Run the two systems in parallel instead. This is the same discipline as making a CRM number and a finance number agree, the one I cover in the reconciliation layer: if the pipeline totals do not tie out, you migrated wrong.

The parallel-run sequence
  1. 1

    Load full volume into a sandbox first

    Real dedup rules set to allow-and-report, real audit fields via Set Audit Fields, dependency-ordered load. Reconcile the reject file to zero-unexplained before anything else.

  2. 2

    Stand up the reconciliation report

    Record counts by object, sum of open pipeline, open deal count, contact-to-account attach rate. Compare Salesforce against live HubSpot. Every number matches or every delta is named and owned.

  3. 3

    Run both systems live for a defined window

    HubSpot stays the source of truth while Salesforce shadows it. Reps can see Salesforce, but the number that counts still comes from the old system until the reconciliation holds two cycles running.

  4. 4

    Cut over only when the reports tie and stay tied

    Two clean cycles, not one. The calendar does not decide readiness; the reject count and the reconciliation delta do.

  5. 5

    Keep the rollback until you formally retire HubSpot

    HubSpot untouched and still authoritative means a bad migration is a two-week extension, not a data-loss incident.

The difference between the two approaches is not effort, it is where the risk lives. The big-bang concentrates every unknown into one weekend with no undo. The parallel run spreads the same work across two cycles where every failure is recoverable.

Big-bang weekend cutover Parallel run to zero-unexplained
Dedup rejects Discovered Wednesday in an unopened CSV Reconciled to zero before cutover
Rollback None, HubSpot already decommissioned HubSpot authoritative until reports tie twice
Audit fields Every record born on migration Saturday Real CreatedDate loaded and verified
Forecast on day one Unmapped stages default silently Every stage mapped and count-checked
The failure mode Data-loss incident Stay on HubSpot two more weeks
Same objects, same volume. The difference is whether a bad load is an incident or a Tuesday.

A worked example that reconciles to the reject file

Take a real shape: 50,000 HubSpot contacts, 12,000 companies, 9,000 deals. Run it down both paths. The big-bang loads Saturday and reports success. The parallel run loads the same volume into a sandbox first and opens the error file. The table below is the 8% on the stat tile, made concrete.

ObjectHubSpot countLoadedRejectedReconciled?
Companies to Accounts12,00011,900100 (domain collisions)Merge decided by hand
Contacts50,00046,0004,000 (8%, dedup match)Named and re-inserted
Deals to Opps9,0008,850150 (unmapped stage)Crosswalk fixed, reloaded

The big-bang path shows 46,000 contacts in the org and calls the job done, because the batch turned green. The 4,000 rejects are 8% of the contact base sitting in a CSV, and the 150 deal rejects are unmapped stages that would have defaulted into the wrong forecast bucket. The parallel run finds all 4,150 in the sandbox dry-run, reconciles each one, and only then lets a cutover date exist. Same load, same tools. One version discovers the 8% Wednesday from a rep complaint, the other discovers it in a sandbox before any real user logs in.

Where migration effort lands
Object mapping is the visible 20%. The 80% that breaks cutovers is dedup, history, and reconciliation, the part nobody scopes.
View as table
ItemValue
Object mapping20%
Dedup & reject reconciliation35%
Field history as data20%
Parallel-run reconciliation25%

S6: Verify, then hold the rollback

After cutover the work is still on probation. Re-run the reconciliation query against production and confirm the counts still tie. Keep HubSpot untouched and read-only for at least one full sales cycle, because the failures that survive a clean dry-run are the ones that only appear under live use: a workflow that fires on a field the migration left blank, a report that filters on a picklist value the crosswalk renamed. Retire HubSpot on a date you choose after the reconciliation holds, not on the cutover date itself.

The runbook is boring on purpose. Nobody writes it down because the exciting part, the object mapping, is the part that already works. The parts that lose your data are the reject file you did not open, the history you assumed would migrate, and the weekend you gave yourself no way to undo. If you own the CRM these tables feed, the same discipline shows up downstream in the version-controlled revenue number: a migrated number you cannot reconcile is a number nobody can defend. Climb the six stages, drive the reject file to zero-unexplained, run parallel for two cycles, and keep HubSpot alive until the numbers tie. Let the cutover date fall out of that process rather than driving it.

salesforce hubspot migration

Keep reading

One email. Every week.

One email a week: a system I built or broke, with the config, the numbers, and what I would change. No roundups, no theory, unsubscribe whenever it stops being useful.

The newsletter opens soon.

Connect a provider in src/config.ts