GTM Engineering
Two CRMs, One Revenue Number, and a Board Deadline
The board wants one CRM by quarter end. Big-bang the merge and you lose the deal history plus a rollback. Parallel-run with a checkpoint keeps both.
· 17 min read
The acquisition closed on a Friday. By Monday there were two revenue orgs, two pipelines, two definitions of “closed won,” and a board that wanted one CRM by quarter end. Nine weeks. Two sales teams still selling into overlapping accounts from two systems that had never heard of each other. The integration lead proposed a big-bang: load the acquired org into ours over a long weekend, decommission theirs, done. I have watched that plan run once, and the scar it left is the reason I now refuse it. Wednesday after the cutover we found 23,000 accounts had merged into the wrong parent, the acquired team’s deal history showed every record born on migration Saturday, and there was no system left to roll back to because we had already turned theirs off.
Post-acquisition CRM consolidation is not a data-loading problem. It is a two-systems-of-record problem wearing a data-loading costume. Two orgs each think they own the truth about the same customer, and the moment you pick one you are throwing away the other org’s version of that customer’s history unless you decide, on purpose and in advance, what survives. Here is the consolidation playbook I run now: map both models, dedup with a key you can defend, land history as data, and cut over behind a rollback checkpoint that you do not remove until the numbers tie twice.
Here is the runbook I run in place of the big-bang. Six rungs, bottom to top, and the order is the safety: each one has to hold before the next earns its place, and Org B stays alive under all of them until the top rung ties twice. This is the framework. Scroll it, then I will show you the object map, the dedup query, the checkpoint gate, and the rollback under every rung.
- L6Cut over behind a live rollbackrollback
Two consecutive exact passes flip authority. Keep Org B breathing behind a Source_Org__c tag until a full close cycle runs clean. A problem in week two is a "stay split" conversation, not a data-loss incident.
- L5Parallel-run two cycles2 cycles
Org B stays authoritative while the survivor shadows it. The number that counts still comes from Org B until the reconciliation ties for two consecutive cycles, because a single clean pass can be luck.
- L4Dry-run the checkpoint gatedelta = 0
Run checkpoint.sh against the staging load once. The unexplained delta it prints is the number of records your big-bang weekend would have silently lost. That count, not the board deadline, tells you whether you are ready.
- L3Land history as dataaudit fields
Field history is not portable. Load the real CreatedDate via Set Audit Fields, and export Org B stage progression into a Legacy_Stage_History__c field so the acquired team's institutional memory survives the load.
- L2Dedupe by which record is alive23K dupes
Load Org B tagged and allow-and-report, then find cross-org collisions by normalized domain and pick each master by freshest activity, not by which org signed the paperwork. On the last merge, 23K of 95K accounts collided.
- L1Map both modelscrosswalk
One row per object, field, and picklist value, with the transformation rule written down so a second person can audit it. Same object names hide different meanings: their seven-value StageName is not your five. The model, not the load, is the hard part.
Locate your merge on that ladder. The big-bang skips straight from L1 to L6 over a long weekend and removes the rollback before it knows whether the numbers tie. Every rung in between subtracts a specific way the merge silently loses a customer’s past. Build them in order and the worst case drops from “incident” to “Tuesday.”
Two orgs, two schemas, one customer
The trap in a two-CRM merge is that both systems have an object called Account and an object called Opportunity, so the mapping looks trivial. It is not the object names that break you. It is that the acquired org’s StageName picklist has seven values and yours has five, their “Account” record type splits Customer from Prospect and yours does not, and their required custom field Region__c maps to nothing in your schema. Same word, different meaning, and the loader does not know the difference.
So the first artifact I build is a crosswalk that names every object, every field, and the transformation rule, one row per source value, written down so a second person can audit it. This is the same discipline I use in a single-system move like the HubSpot to Salesforce migration runbook; the difference here is that both sides are live production orgs, and both sides have opinions. If you want the deeper treatment of why the model, not the load, is the hard part, that lives in CRM data modeling.
Here is the mapping as config, not prose, because the loader reads config and a spreadsheet lies:
# object-map.yml (source = acquired org "B", target = survivor org "A")
Account:
match_key: normalized_domain # strip www, lowercase, trim TLD noise
fields:
Name: { to: Name }
Website: { to: Website, transform: normalize_domain }
Type: { to: Type, map: { "Client": "Customer", "Lead": "Prospect" } }
Region__c: { to: Sales_Region__c, required_in_A: true, default: "Unknown" }
OwnerId: { to: OwnerId, transform: user_xref } # cross-org user lookup
Opportunity:
match_key: [AccountId, Name, CloseDate]
fields:
StageName: { to: StageName, map_file: stage_crosswalk.csv, no_default: true }
Amount: { to: Amount, transform: currency_check } # both orgs USD, verify
CreatedDate: { to: CreatedDate, requires: set_audit_fields }
Legacy_Stage_History__c: { to: Legacy_Stage_History__c } # history-as-data
Contact:
match_key: email
fields:
Email: { to: Email }
AccountId: { to: AccountId, transform: account_xref } # resolve after Account load
The rule that saves you: no_default: true on StageName. An unmapped stage that falls to a default value corrupts the forecast the moment the acquired team logs in. Map every picklist value by hand, or fail the row loudly, but never let a stage land as a silent default.
| Source (Org B) | Target (Org A) | Transformation rule |
|---|---|---|
| Account.Website | Account.Website | Normalize to domain; this becomes the dedup key |
| Account.Type “Client” | Account.Type “Customer” | Explicit value map, no passthrough |
| Opportunity.StageName | Opportunity.StageName | Per-value crosswalk file; unmapped row fails, no default |
| Opportunity.CreatedDate | Opportunity.CreatedDate | Real date via Set Audit Fields, verified against sample |
| Contact.OwnerId | Contact.OwnerId | Cross-org user xref table (IDs do not survive orgs) |
| (no equivalent) | Account.Source_Org__c | Stamp every migrated row so rollback can find it |
That last row is the one people skip and regret. Stamp every migrated record with a Source_Org__c tag on the way in. If the merge goes wrong, that field is the difference between a clean WHERE Source_Org__c = 'B' rollback and a forensic reconstruction at 2am.
Dedup is a decision, not a rule you turn on
The two orgs share customers. They sold into the same market, so the acquired org’s book overlaps yours. When I profiled the last one, 95,000 accounts came in from Org B and 23,000 of them matched an account already living in Org A. That is the 23K in the stat tile up top, and it is where every silent data-loss incident hides.
Salesforce duplicate rules can block or allow-and-report on insert. Block mode looks safe and behaves like a shredder: the Bulk API rejects the matching row, writes it to an error file, and reports the job as “succeeded” because the batch ran. Load 95,000 accounts with a block rule and you end up with 72,000 in the org and 23,000 in a CSV nobody opened. Worse, in a two-org merge the survivor is the wrong place to let a machine decide the merge, because the acquired org often has the fresher record. Their rep talked to that customer last week; your record has not moved in a year.
So dedup runs in two passes. First, load everything into a staging state with duplicate rules set to allow-and-report, tagged with Source_Org__c = 'B'. Then find the cross-org collisions deliberately with a query you can read, and decide field-by-field which org wins each surviving record:
-- Cross-org duplicate accounts by normalized domain.
-- Runs in the survivor org AFTER Org B is loaded as tagged/allow-and-report.
SELECT Clean_Domain__c,
COUNT(Id) dupes,
MIN(CreatedDate) oldest,
MAX(LastActivityDate) freshest_touch
FROM Account
WHERE Clean_Domain__c != null
GROUP BY Clean_Domain__c
HAVING COUNT(Id) > 1
ORDER BY COUNT(Id) DESC
The freshest_touch column is the tell. When the freshest activity on a matched pair belongs to the Org B record, the acquired team owns the live relationship and Org B’s field values should win the merge even though Org A is the survivor org. Pick the master by which record is alive, not by which org signed the acquisition paperwork.
Here is why the deduping is not optional, with the number attached. Two orgs drift the second the deal closes: reps stop updating the org they are told is going away, and contact data decays at roughly 30% a year (ZoomInfo). A duplicate is not one wasted row. It is two reps calling the same buyer, two forecasts counting the same deal, and a support team routing a case to an owner who left. Gartner puts the cost of poor data quality near $15M a year for the average organization; a botched merge is how you buy the deluxe version of that bill in one weekend.
| What a cross-org duplicate costs | Where it lands | Source |
|---|---|---|
| Contact records rotting between two systems | ~30% of contacts stale within a year | ZoomInfo |
| Poor-quality data, org-wide, annualized | ~$15M/yr average | Gartner |
| Double-counted pipeline | Forecast overstated by the overlap | First-person: 23K of 95K accounts overlapped |
| Two reps, one buyer | Comp disputes, buyer annoyance | First-person, every merge I have run |
The chart makes the dedup real. Toggle between what arrived from Org B and what was net-new after the match pass. Accounts drop from 95K to 72K; contacts from 310K to 244K. Opportunities and cases barely move, because they belong to a record, not a market, so they rarely collide.
View as table
| Item | Value |
|---|---|
| Accounts | 95K |
| Contacts | 310K |
| Opportunities | 41K |
| Cases | 88K |
Field history does not migrate, so land it as data
Neither org’s field history is portable. You cannot insert “this deal moved to Proposal on March 3rd by Dana” into Salesforce’s history tables; those tables are written by the platform, not by a loader. So on a naive merge, every migrated record’s stage history begins on migration day, and the acquired team’s institutional memory of how deals progressed evaporates. Reps notice within hours, because the first thing an acquired-team rep does is open a familiar account and find its past erased.
Two moves fix it. Load the real CreatedDate through the Set Audit Fields org preference so records do not all claim to be born on migration Saturday. That preference works on insert only, and only while it is enabled, so the sequence is tight: enable it, load with verified dates against a sample, then disable it again so nobody backdates a record after go-live. For the stage progression that reps depend on, export it from Org B and land it as data in a Legacy_Stage_History__c long-text or a related custom object, so the story survives even though the native history cannot.
Decide up front which history matters, because you cannot keep all of it and pretending you can is how the reconciliation balloons past the deadline. Reps need two things from the past: when the deal entered each stage, and who owned it when. Land those, let the rest go.
Parallel-run behind a rollback checkpoint
Now the spine of the whole playbook. The big-bang concentrates every unknown into one weekend and gives you no clean state to return to. The parallel run spreads the same work across two reconciliation cycles where every failure is recoverable, because Org B stays alive and authoritative until the numbers say otherwise.
The reconciliation report is the heart of the parallel run. Before any cutover date exists, you stand up counts that must match Org B’s live numbers or the delta must be named and owned. Two cycles, not one, because a single clean reconciliation can be luck.
The checkpoint itself is a script with a gate, not a meeting where someone says “looks good.” Codify it so the decision is mechanical:
#!/usr/bin/env bash
# checkpoint.sh: gate the cutover on the reconciliation delta.
# Exits non-zero (blocks cutover) unless every object ties within tolerance.
set -euo pipefail
TOLERANCE=0 # accounts/opps must be exact; 0 unexplained rows allowed
for obj in Account Contact Opportunity Case; do
a=$(sf data query -o survivorA -q "SELECT COUNT() FROM ${obj} WHERE Source_Org__c='B'" --json | jq '.result.totalSize')
b=$(sf data query -o orgB -q "SELECT COUNT() FROM ${obj}" --json | jq '.result.totalSize')
# b minus known-merged dupes for that object, from the dedup pass:
expected=$(( b - $(cat merged_${obj}.count) ))
delta=$(( a - expected ))
echo "${obj}: A=${a} expected=${expected} delta=${delta}"
if [ "${delta#-}" -gt "$TOLERANCE" ]; then
echo "BLOCK: ${obj} off by ${delta}. Cutover halted. Org B stays authoritative."
exit 1
fi
done
echo "PASS. Record cycle result. Cut over only after TWO consecutive passes."
Rollback is the twin of that gate, and it is trivial precisely because you stamped Source_Org__c on the way in. If the checkpoint fails after go-live, or the acquired team hits a wall in week two, you delete the migrated rows and point everyone back at a still-running Org B:
# rollback.sh: only possible because Org B was never decommissioned.
sf data delete bulk -o survivorA -s Opportunity --where "Source_Org__c='B'"
sf data delete bulk -o survivorA -s Contact --where "Source_Org__c='B'"
sf data delete bulk -o survivorA -s Account --where "Source_Org__c='B'"
# Org B was live the whole time. This is a "stay split two more weeks"
# conversation, not a data-loss incident.
- 1
1. Freeze both schemas and snapshot the counts
Lock field and picklist changes in both orgs and capture record counts by object and total open pipeline in each. These snapshots are the baseline every later reconciliation compares against. A schema that moves mid-merge invalidates the crosswalk you are about to build.
- 2
2. Build the two-org crosswalk and stage map
One row per object, field, and picklist value, with the transformation rule written down. Map every StageName by hand with no default. Add Source_Org__c to the survivor so every migrated row is tagged for rollback.
- 3
3. Load Org B into the survivor as tagged and deduped
Dependency order (Accounts, then Contacts, then Opportunities), duplicate rules set to allow-and-report, real CreatedDate via Set Audit Fields, stage history landed as data. Reconcile the reject file to zero-unexplained before anything downstream.
- 4
4. Resolve cross-org duplicates by which record is alive
Run the domain-keyed duplicate query. Route domain-collision-but-different-parent accounts to manual review. Pick each master by freshest activity, not by which org is the survivor. Record merged counts per object for the checkpoint math.
- 5
5. Run both orgs live in parallel for two cycles
Org B stays the source of truth while the survivor shadows it. Reps can see the merged org, but the number that counts still comes from Org B until the reconciliation holds for two consecutive cycles.
- 6
6. Pass the checkpoint gate twice, then cut over
Run checkpoint.sh each cycle. Two consecutive exact passes flip authority to the survivor org. The board deadline does not decide readiness; the delta does. If week nine arrives and the gate has not passed twice, you extend the parallel run, you do not ship a bad merge.
- 7
7. Hold the rollback until you formally decommission Org B
Keep Org B alive and untouched past cutover. A problem found in week two is a "delete Source_Org__c=B and stay split" conversation, not an incident. Retire Org B only after the merged org has carried a full close cycle clean.
| Big-bang weekend cutover | Parallel run with a checkpoint | |
|---|---|---|
| Cross-org dedup | Machine merges at insert, no log read | Deliberate merge by freshest record, reviewed |
| Rollback | None. Org B already decommissioned | Delete by Source_Org__c; Org B still live |
| Field history | Every record born on migration Saturday | Real CreatedDate plus stage history as data |
| Unmapped stage | Falls to default, corrupts forecast silently | Row fails loudly, crosswalk gets fixed |
| Readiness signal | The calendar (board deadline) | Two consecutive checkpoint passes |
| Failure mode | Data-loss incident, no undo | "Stay split two more weeks" |
What the board needs to hear
The board asked for one CRM by quarter end, and the honest answer is that the deadline governs when you start, not when you cut over. A merge shipped on a date instead of on a reconciliation is how you turn a nine-week project into a two-quarter cleanup. Give the board the checkpoint instead of the calendar: here is the gate, here is the delta this cycle, here is the date it will pass if it holds. That is a number they can govern, and it is far better than a green weekend that quietly cost 23,000 accounts.
The consolidation playbook is boring on purpose, and the boring parts are the ones that keep the deal history and the undo button. Map both models before you trust the shared object names. Dedup by which record is alive, not which org won. Land the history you cannot migrate as data. And keep the acquired org breathing behind a Source_Org__c tag until the checkpoint ties twice. Let the cutover date fall out of that gate. The board gets its one CRM, and you get to keep the version where nobody loses a customer’s past on a Saturday.
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