Start with a concrete scenario. Your sales team uses Salesforce. Your finance team exports data monthly from a billing system. Your data engineering team has built a Snowflake model that joins the two on company name to generate a revenue forecast by account segment.
The Salesforce records for a single company look like this in your warehouse:
company_name | account_id
-----------------------+-----------
Veltrix Analytics | SF-001234
Veltrix Analytics Inc. | SF-001891
Veltrix Analytical | SF-002045
VELTRIX ANALYTICS | SF-003102
Your finance billing system has three records for the same company under different historical billing names. Your join on company name produces up to twelve permutations when it should produce one. The account segment assigned to each varies based on which salesperson created the record and which rep territory mapping was active at the time. The revenue forecast for this account is now split across multiple segment buckets, each showing a fraction of the real figure.
How Duplicates Accumulate
CRM duplication doesn't happen because teams are careless. It happens because CRM entry is distributed. In a growing sales team, each rep creates the accounts they work with. There is no enforcement preventing a new rep from creating a record for a company that already exists under a slightly different name. Standard CRM fuzzy match alerts (Salesforce's native duplicate detection, for instance) operate at record creation time and only catch exact or near-exact matches.
The gaps where duplicates slip through:
- Legal entity variations: "Acme Corp" vs. "Acme Corporation" vs. "Acme Corp." with a trailing period
- Abbreviations: "International Business Machines" vs. "IBM"
- Domain-based entries: email domain
@veltrix.comcreates a company record separate from an existingVeltrix Analytics Inc - Acquisitions: "OldName Inc" was acquired and is now "NewParent - OldName Division" in some records
- Manual imports: a CSV import of accounts from a partner doesn't de-duplicate against existing records
Over a two-year period for a sales team of 20, a CRM export of 40,000 account records typically contains somewhere between 8% and 15% duplicates once you run a proper fuzzy match. The range is wide because it depends heavily on how disciplined the team's CRM hygiene practices are and whether any merging was done manually in the past.
How Duplicates Propagate Downstream
The account-level CRM table is usually not the end of the story. That table feeds other things: revenue attribution models, churn prediction models, lead scoring, and outbound campaign segmentation. Each of these downstream consumers sees the duplicate structure as the authoritative state of the world.
Churn prediction gets it wrong in a specific way. If two duplicate records exist for the same account, one showing high engagement and one showing low engagement, the model may score them independently. The low-engagement duplicate can trigger an automated re-engagement campaign to a company that is already an active customer with a healthy relationship. That's not just a data quality problem; it's a customer experience problem that reached production.
Revenue attribution models overcount when the same deal appears under multiple account IDs. A new business deal of $200K attributed to "Veltrix Analytics" and an upsell of $50K attributed to "Veltrix Analytics Inc." look like two separate customers in your segment totals. Your new-business vs. expansion revenue split is wrong. Your segment ARR calculation is off. These errors compound every time the model runs because the wrong numbers become the baseline for the next period's comparisons.
The Measurement Problem
You can't easily measure how much CRM duplication is costing you without first resolving the duplicates. This is circular: the cost of the problem is quantified by fixing it.
Here is a practical approach to estimating the scope without a full resolution run. Take a sample of 500 account records. Run a basic fuzzy match on company name alone (Jaro-Winkler similarity, threshold 0.85). Count the number of records that have at least one near-match in the sample. Extrapolate to your full dataset. For a typical B2B CRM, this gives you an order-of-magnitude estimate before committing to a full resolution pass.
from jellyfish import jaro_winkler_similarity
import pandas as pd
def estimate_duplicate_rate(accounts: pd.DataFrame, sample_n=500, threshold=0.85):
sample = accounts['company_name'].sample(sample_n).tolist()
matches = 0
for i, name_a in enumerate(sample):
for name_b in sample[i+1:]:
if jaro_winkler_similarity(name_a.lower(), name_b.lower()) >= threshold:
matches += 1
break
return matches / sample_n
This is a rough estimate, not a precise count. The actual resolution pass will find more duplicates than this because it uses additional features beyond name similarity (address, domain, phone number, deal history). But it gives you a percentage to put in front of stakeholders when making the case for a cleanup project.
What Resolution Actually Produces
Entity resolution against a CRM export produces a mapping: for each cluster of duplicate records, one is designated the canonical record and the others are linked to it with a resolution status. The output is not a deletion of duplicate records, though many teams eventually delete or archive the non-canonical ones.
{
"canonical_id": "SF-001234",
"duplicates": [
{"id": "SF-001891", "confidence": 0.97, "match_features": ["name", "domain"]},
{"id": "SF-002045", "confidence": 0.91, "match_features": ["name", "billing_address"]},
{"id": "SF-003102", "confidence": 0.99, "match_features": ["name", "phone"]}
]
}
The confidence scores matter here because resolution is not a binary outcome. A 0.97 confidence match is one where you're almost certainly looking at the same company. A 0.65 match is one where a human should review. Bulk-merging everything above 0.80 without review will produce some incorrect merges, which are harder to undo than the original duplicates were. The right threshold depends on your data and your tolerance for false positives.
Preventing Accumulation vs. Fixing What Exists
Resolution is a repair operation. Once you've run it, you need an ingestion-time check to prevent duplicates from re-accumulating. The check looks different depending on your CRM integration pattern.
If you pull CRM exports into a warehouse, the check runs at ingestion: each new record is compared against your canonical entity store before being written as a new account. If the match score exceeds your threshold, the record is linked to the existing canonical entity rather than written as a new row.
If you maintain a golden record table (a deduplicated table your models join against), you update that table incrementally: new records that resolve to existing entities update the existing canonical record's attributes; records that don't resolve to any existing entity create new canonical entries.
Neither approach eliminates manual CRM maintenance entirely. Records with names that are too different to fuzzy match (a new legal entity formed by a merger, for example) will still create new entries that need a manual merge trigger. But the volume of manual work drops significantly once the systematic matches are handled automatically.