CRM data hygiene: cleaning contacts by email domain
· 6 min read · Company enrichment
The email domain is the most reliable join key in a B2B CRM, so cleaning by domain fixes more than any other single pass. Normalize every contact's email domain and every company's domain field to the registrable domain, classify each domain (company, free webmail, ISP, disposable, educational, government, invalid), then act per class: merge duplicate accounts, detach personal addresses from fake companies, suppress throwaway contacts. Do it in that order, and then fix the forms so you don't have to do it again.
This guide is for RevOps and the engineers who support them. It works with any CRM that exports to CSV and accepts updates back by record ID.
What domain-based cleanup actually fixes
Names are messy: "Acme", "Acme Inc.", "ACME Corporation", "Acme (old)". Domains are much less so, once normalized. A domain pass catches:
- Duplicate accounts.
acme.com,www.acme.com,https://acme.com/,eu.acme.comas four companies. - Fake companies. A company record called "Gmail" with 3,000 unrelated contacts attached, created by auto-association.
- Throwaway contacts. Signups from disposable domains polluting lead counts and campaign metrics.
- Non-person contacts.
info@,noreply@andbilling@treated like decision makers. - Invalid addresses. Typos and junk that will never deliver.
What it won't fix: people who changed jobs, wrong titles, or two companies that genuinely share a domain. Those need other data.
Step 1: export and back up
Export contacts (ID, email, associated company ID, lifecycle stage, owner) and companies (ID, name, domain field, owner, open deal count). Keep the raw export untouched. Every change you make later should be a row in an import file you can review and reverse.
Step 2: normalize domains to the registrable domain
Company domain fields collect every format a human can type. Normalize both sides with the same function:
import { getDomain } from 'tldts';
import { domainToASCII } from 'node:url';
export function normalizeDomain(value) {
if (!value) return null;
let s = String(value).trim().toLowerCase();
if (s.includes('@')) s = s.slice(s.lastIndexOf('@') + 1); // email → host
s = s.replace(/^[a-z]+:\/\//, '').split(/[/?#:]/)[0]; // drop scheme, path, port
s = s.replace(/^www\./, '').replace(/\.$/, '');
const ascii = domainToASCII(s); // IDN → punycode, '' if invalid
return ascii ? getDomain(ascii) : null; // eTLD+1, null if not a real domain
}
normalizeDomain('https://www.Acme.com/contact'); // 'acme.com'
normalizeDomain('jane@eu.acme.com'); // 'acme.com'
normalizeDomain('shop.acme.co.uk'); // 'acme.co.uk'
Collapsing to the registrable domain, not just stripping www., is the point. HubSpot's automatic company creation, for example, creates a separate company for each unique domain, including subdomains, so @example.com and @info.example.com produce two companies. If you're wondering why the library call is needed instead of keeping the last two labels, eTLD+1 explained has the co.uk story.
Step 3: classify each unique domain once
A CRM with 80,000 contacts might have only a few thousand distinct domains. Classify the domains, not the contacts. Free webmail and disposable lists are public, so you can do this with your own lists, but you'll be maintaining roughly 100 webmail domains, around 60 ISP mail domains and a disposable list that changes every week.
With pingutil, a bare-domain lookup with skip_enrichment: true returns the category without calling the LLM. The per-key limit is 60 requests a minute, so throttle:
// classify-domains.mjs (Node 18+)
// usage: node classify-domains.mjs domains.txt > categories.csv
import { readFileSync } from 'node:fs';
const domains = [...new Set(readFileSync(process.argv[2], 'utf8').split('\n').map(s => s.trim()).filter(Boolean))];
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
console.log('domain,category');
for (const domain of domains) {
for (;;) {
const res = await fetch('https://pingutil.com/v1/lookup', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.PINGUTIL_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({ input: domain, skip_enrichment: true }),
});
const data = await res.json();
if (res.status === 429) { await sleep((data.retry_after_seconds ?? 60) * 1000); continue; }
console.log(`${domain},${res.ok ? data.category : 'unknown'}`);
break;
}
await sleep(1050); // stay just under 60/min
}
Three thousand domains takes about an hour. If you'd rather work row by row in a spreadsheet, the CSV cleanup script covers parsing and joining results back.
Step 4: act per category
This is where opinions matter. Here's what we'd do:
| Category | Contacts | Company association |
|---|---|---|
company |
Keep | Associate to the one account for that registrable domain |
free |
Keep, score as personal | Remove auto-created "Gmail" style companies |
isp |
Keep, score as personal | Same as free |
educational |
Keep, separate segment | Group by institution only if you sell to institutions |
government |
Keep, separate segment | Same as educational |
disposable |
Suppress from marketing; delete if never engaged | None |
unknown |
Fix typos you can see, otherwise suppress | None |
A few notes on the less obvious rows.
Free webmail is not junk. Founders, freelancers and plenty of real buyers use Gmail. The problem is the company record. Find every company whose normalized domain is a free or ISP domain, detach its contacts, and delete it. HubSpot already treats free email domains differently when auto-associating and checks the contact's website field instead; other CRMs may not, and older data may predate the setting. Why comcast.net is not a company covers the ISP cases people forget.
Disposable contacts are mostly worthless, but check before deleting. Someone who used a throwaway address and then paid you is a customer. Filter by lifecycle stage and deal history first. Everything else can go to a suppression list so it stops counting toward MQL numbers.
Universities are not one account. harvard.edu contains thousands of people who have never heard of each other. Unless you sell site licenses, don't roll them into one company.
Step 5: merge duplicate accounts
Group companies by normalized domain. Any group with more than one record is a merge candidate. Pick the survivor by rules you decide in advance, for example:
- The record with open deals wins.
- Otherwise the record with the most associated contacts.
- Otherwise the oldest.
Keep the survivor's owner unless the rules say otherwise, and write the losing records' IDs into a note field on the survivor. Run merges in small batches and spot-check the first batch by hand. Merging is the step where a bad rule does the most damage, and merges are often hard or impossible to reverse, so check your CRM's documentation before running them in bulk.
Watch for two traps. Some companies operate several domains (regional ccTLDs, a product domain, an acquired brand). Domain grouping won't merge those, and that's the right default; handle them with a small manual alias table. And a few domains really are shared by unrelated organizations, such as co-working spaces or hosting providers that give customers addresses. If a "company" has contacts from wildly different industries, look before merging.
Step 6: flag non-person addresses
Role addresses like info@, sales@ and support@ reach a team, not a person. System addresses like noreply@ and mailer-daemon@ reach no one. Keep role addresses attached to the account (they're sometimes the only contact you have) but exclude them from persona-based sequences and from "contacts per account" metrics. Drop system addresses from marketing entirely.
You can match common prefixes locally. pingutil also flags them per address in email_signals (is_role_based, is_system_generated) and verdict.low_trust, but that's one request per contact, which is slow for a large backfill at 60 a minute. For the cleanup, a local list of prefixes is fine; use the API for new contacts. Role-based email addresses goes into which ones to keep.
Step 7: stop it coming back
A cleanup without input fixes lasts a quarter. The durable changes:
- Normalize the domain on write. Whatever creates company records (forms, integrations, imports) should store the registrable domain, never the raw website string.
- Classify at the form. Block disposable domains at signup, and tag free and ISP addresses as personal before they reach the CRM. The HubSpot workflow webhook guide shows one way to do this inside HubSpot.
- Audit on a schedule. Re-run the domain classification monthly on contacts created since the last run. Disposable lists change weekly, so a domain that was clean in March might not be in June.
- Enrich after cleaning. If you add firmographics, do it once accounts are deduplicated, so you enrich each company once.
The one-line version: pick the registrable domain as your account key, classify every domain, and make every system that writes to the CRM use the same normalization function.
Questions people ask
- Should I delete contacts with free email addresses like Gmail from my CRM?
- No. Many real buyers, founders and consultants use personal addresses. Keep them, but don't associate them with a company called Gmail, and score them differently from contacts on a company domain.
- How do I find duplicate company records in a CRM?
- Normalize every company's domain field to its registrable domain (lowercase, no protocol, no path, no www, subdomains collapsed), then group by that value. Any group with more than one record is a merge candidate. Name-based matching finds more candidates but with far more false positives.
- How often should I clean CRM data?
- Do one thorough cleanup, then fix the inputs so new records arrive clean, and run a lighter domain-based audit monthly or quarterly. Most of the value comes from stopping bad records at the form.