How to Automate CRM Data Cleanup with n8n
The Problem
CRM data decays quietly. Sales reps type the same person twice with a slightly different email, forms create contacts without a company or job title, and records go untouched for months while still counting toward your contact limits. Native duplicate tools only catch exact matches, and n8n can fill that gap, and nobody has time to export a CSV and scan it each week. This guide builds an n8n workflow that pulls all HubSpot contacts every Sunday evening, finds near duplicates with fuzzy matching, scores each record for completeness, flags stale contacts, and emails the CRM admin a clear report with a comparison to the previous week.
What You Will Need
- A self-hosted n8n instance (Community Edition is free) or an n8n Cloud trial
- A HubSpot private app token with the crm.objects.contacts.read scope, saved as an n8n credential
- SMTP details for the n8n Send Email node, or a mail service account usable from n8n
- About 60 minutes of setup time, plus a read only first run to review the results
Step-by-Step Workflow Setup
1 Schedule the Sunday run
In n8n, a weekly cadence keeps the report actionable and gives the admin a fresh list for Monday morning. The Schedule Trigger node in n8n supports weekday selection.
Add a Schedule Trigger node to a new n8n workflow and configure:
Node: Schedule Trigger
Trigger Rules: Add Rule
Trigger Interval: Weeks
Weeks Between Triggers: 1
Trigger on Weekdays: Sunday
Trigger at Hour: 6pm
Trigger at Minute: 0
Running n8n on Sunday evening also avoids API contention with reps who work in HubSpot on weekdays.
{
"timestamp": "2026-11-08T18:00:00.000-08:00",
"Day of week": "Sunday"
}
2 Get all HubSpot contacts
The HubSpot node can fetch every contact and handles pagination for you when Return All is on. Request only the properties you need, which keeps the n8n execution light and fast.
Add a HubSpot node to the n8n canvas and configure:
Node: HubSpot
Authentication: App Token
Resource: Contact
Operation: Get Many
Return All: On
Additional Fields > Properties: email, firstname, lastname, phone, company, jobtitle, lastmodifieddate, notes_last_updated
Additional Fields > Property Mode: Value Only
If your account holds several hundred thousand contacts, run this node in a sub workflow, because n8n keeps all items in memory during an execution.
{
"id": "5120",
"properties": {
"email": "Jane.Doe@Example.com ",
"firstname": "Jane",
"phone": "+1 (415) 555-0134",
"company": "",
"jobtitle": "Head of Ops",
"notes_last_updated": "2026-06-12T10:20:00Z"
}
}
3 Normalize and detect duplicates
The first n8n Code node lowercases and trims emails, strips phone formatting, then compares pairs using Levenshtein distance. Comparing every pair is quadratic, so the code groups contacts by email domain first and only compares inside each group.
Add an n8n Code node and configure:
Node: Code
Mode: Run Once for All Items
Language: JavaScript
function lev(a, b) {
const d = Array.from({ length: a.length + 1 }, (_, i) => [i]);
for (let j = 1; j <= b.length; j++) d[0][j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
d[i][j] = Math.min(d[i-1][j] + 1, d[i][j-1] + 1, d[i-1][j-1] + (a[i-1] === b[j-1] ? 0 : 1));
}
}
return d[a.length][b.length];
}
const contacts = $input.all().map(i => {
const p = i.json.properties;
return { id: i.json.id, ...p,
email: (p.email || '').trim().toLowerCase(),
phoneDigits: (p.phone || '').replace(/\D/g, '') };
});
const byDomain = {};
contacts.forEach(c => { const dom = c.email.split('@')[1] || 'none'; (byDomain[dom] = byDomain[dom] || []).push(c); });
const pairs = [];
for (const list of Object.values(byDomain)) {
for (let i = 0; i < list.length; i++) for (let j = i + 1; j < list.length; j++) {
const a = list[i], b = list[j];
const emailDist = lev(a.email.split('@')[0], b.email.split('@')[0]);
const samePhone = a.phoneDigits.length > 6 && a.phoneDigits === b.phoneDigits;
if (emailDist <= 2 || samePhone) pairs.push({ idA: a.id, idB: b.id, emailA: a.email, emailB: b.email, emailDist, samePhone });
}
}
return [{ json: { contacts, pairs } }];
A distance of 2 or less catches typos like jane.doe and jane.do. Raise or lower the threshold depending on how noisy your data is. Matching the same phone digits catches duplicates whose emails differ entirely.
{
"idA": "5120",
"idB": "7788",
"emailA": "jane.doe@example.com",
"emailB": "jane.do@example.com",
"emailDist": 1,
"samePhone": true
}
4 Filter for missing required fields
In n8n, split the contacts into separate items with a Split Out node on the contacts field, then use a Code node to add a completeness score. A Filter node keeps only contacts that miss a required field.
Node: Code (score)
Mode: Run Once for Each Item
const req = ['email', 'company', 'jobtitle'];
const missing = req.filter(f => ($json[f] || '').toString().trim() === '');
return { json: { ...$json, missing, completeness: Math.round(100 * (req.length - missing.length) / req.length) } };
Node: Filter
Conditions: Number
Value 1: {{ $json.missing.length }}
Operation: Is Greater Than
Value 2: 0
{
"id": "5120",
"email": "jane.doe@example.com",
"missing": ["company"],
"completeness": 67
}
Because the duplicate pairs live in the earlier n8n item, reference them later by node name, for example $('Code').first().json.pairs.
5 Flag stale contacts
Staleness uses a configurable threshold. A third n8n Code node reads the full contact list from step 3, so the n8n stale check covers every record, not only the incomplete ones.
Node: Code (stale)
Mode: Run Once for All Items
const days = 90;
const cutoff = Date.now() - days * 86400000;
const all = $('Code').first().json.contacts;
const stale = all.filter(c => {
const t = Date.parse(c.notes_last_updated || c.lastmodifieddate || '');
return isNaN(t) ? true : t < cutoff;
}).map(c => ({ id: c.id, email: c.email, last: c.notes_last_updated || c.lastmodifieddate || 'never' }));
return [{ json: { stale, threshold: days, missingList: $input.all().map(i => i.json) } }];
Move the days value into a Set node or an n8n environment variable if different teams want different thresholds, such as 60 days for sales and 180 days for partners.
6 Compile the HTML report with trends
The final n8n Code node builds the HTML. For the trend, it stores this week totals in workflow static data and compares them with last week.
Node: Code (report)
Mode: Run Once for All Items
const store = $getWorkflowStaticData('global');
const pairs = $('Code').first().json.pairs;
const d = $input.first().json;
const now = { dup: pairs.length, missing: d.missingList.length, stale: d.stale.length };
const prev = store.last || { dup: 0, missing: 0, stale: 0 };
const delta = k => (now[k] - prev[k] >= 0 ? '+' : '') + (now[k] - prev[k]);
store.last = now;
const row = (a, b) => '<tr><td>' + a + '</td><td>' + b + '</td></tr>';
const html = '<h2>CRM Hygiene Report</h2>'
+ '<p>Duplicates: ' + now.dup + ' (' + delta('dup') + ' vs last week). Missing fields: ' + now.missing + ' (' + delta('missing') + '). Stale: ' + now.stale + ' (' + delta('stale') + ').</p>'
+ '<h3>Duplicate pairs</h3><table border="1" cellpadding="4">' + pairs.slice(0, 50).map(p => row(p.emailA, p.emailB)).join('') + '</table>'
+ '<h3>Missing fields</h3><ul>' + d.missingList.slice(0, 50).map(m => '<li>' + m.email + ': ' + m.missing.join(', ') + '</li>').join('') + '</ul>'
+ '<h3>Stale contacts</h3><ul>' + d.stale.slice(0, 50).map(s => '<li>' + s.email + ' (last ' + s.last + ')</li>').join('') + '</ul>';
return [{ json: { html, now } }];
{
"now": { "dup": 14, "missing": 212, "stale": 480 },
"html": "<h2>CRM Hygiene Report</h2>..."
}
Workflow static data persists only for active, production executions, so the trend lines appear from the second scheduled run onward.
7 Email the hygiene report
The n8n Send Email node delivers the report over SMTP.
Add a Send Email node and configure:
Node: Send Email
Credential: SMTP
From Email: reports@yourcompany.com
To Email: crm-admin@yourcompany.com
Subject: CRM hygiene report for {{ $now.format('yyyy-MM-dd') }}
Email Format: HTML
HTML: {{ $json.html }}
Keep this n8n workflow read only at first. Once the admin trusts the lists, a later n8n workflow can merge confirmed duplicates through the HubSpot API.
Troubleshooting
The n8n Code node runs out of memory or times out on a large CRM
The pair comparison grows quickly with group size. Group by the full email domain, skip free mail domains such as gmail.com in the duplicate loop, and raise the task runner timeout in your n8n settings if needed.
The n8n HubSpot node returns far fewer contacts than expected
Check the private app scopes used by n8n and confirm Return All is on. Contacts you cannot see because of team permissions are also excluded, so create the token with an admin user.
The n8n trend numbers reset to zero every week
In n8n, static data only saves for production executions, not manual tests. Activate the workflow, and note that exporting and reimporting it clears the stored values.
Free n8n Workflow Resources
Get started with the free workflow pack on the LightCube Labs templates page.
Production-Ready Workflow Packs
Get this workflow and more, with setup guides, editable configurations, and a shared failure-alert system.
View the Starter ($29) and Pro ($99) PacksFrequently Asked Questions
Why does n8n use Levenshtein distance instead of exact email matching?
Exact matching in n8n or anywhere else misses typos and variants such as jane.doe and jane.do. Levenshtein distance counts single character edits, so a threshold of 1 or 2 finds near matches while ignoring unrelated addresses.
How is the completeness score calculated in this n8n workflow?
In n8n, the score is the share of required fields that are filled. With three required fields, a contact missing only company scores 67. You can add more fields or weight them differently in the Code node.
What staleness threshold should I choose in n8n?
Start at 90 days in the n8n Code node, review the first report, and adjust. Long sales cycles justify 180 days, while high volume lead lists may need 60.
Will this n8n workflow change or delete any contacts?
No. It only reads contacts and sends a report, so the first runs are safe. Any merging or cleanup should stay a deliberate step performed by the admin.