CRM Hygiene: A Practical Checklist and How to Keep Job Data Current
Most CRM hygiene advice stops at "dedupe and enrich". This guide gives the whole recurring checklist, with the native HubSpot and Salesforce tool for each job and the plan it needs. It then goes deep on the one job that keeps failing quietly: titles and companies that were right last year.
The short version. CRM hygiene is the routine that keeps records accurate and usable: remove duplicates, enforce required fields and formats at entry, validate emails, fill gaps, refresh fields that go stale, give every record and deal an owner, keep deal stages and close dates current, retire inactive records, and agree who owns each standard. HubSpot and Salesforce have native tools for most of it, and HubSpot's enrichment even refreshes the values it filled, monthly – until someone edits them. Job title and company are where records drift quietly, because people change jobs: the median US employee has been with their current employer 4.1 years. Where native refresh cannot reach – fields someone edited, people the data vendor does not have – re-check the contact's saved LinkedIn profile on a schedule, send mismatches to a review queue instead of overwriting, and keep the old value.
What is CRM hygiene?
CRM hygiene is the recurring work that keeps the records in your CRM complete, consistent and current, so that routing, reporting and outreach run on facts rather than on last year's guesses. It is a schedule, not a one-off cleanup: data starts drifting again the day after the cleanup ends.
The cost of skipping it is hard to measure precisely, but CRM admins feel it. In Validity's 2022 survey of 1,241 CRM users, "44% of respondents estimate their company loses over 10% in annual revenue due to poor quality CRM data" (Validity). That is their estimate, not a measured loss.
The CRM hygiene checklist
Cadences below are a sensible default; adjust them to how fast your records change.
Contact data
| Job | Who | How often | HubSpot | Salesforce |
|---|---|---|---|---|
| Remove duplicates | Ops or admin | Weekly, and on every import | Review duplicate pairs in the data quality tools (Professional or Enterprise); bulk merge, custom rules and duplicate alerts need Data Hub Professional or Enterprise | Matching rules and duplicate rules that warn or block; org-wide duplicate jobs on Performance and Unlimited |
| Enforce required fields and approved values; standardize formats | Admin | On entry, plus a monthly sweep | Required properties on the create-record form (Starter and up), dropdown properties (all plans), formatting issues to accept or reject | Required fields, picklists and validation rules |
| Validate emails | Marketing ops | Before each campaign | An email-verification service | An email-verification service |
| Fill gaps | Ops | On creation | Enrichment set to fill empty values only | An enrichment app or vendor |
| Refresh stale contact fields | Ops, with rep review | Monthly for active records, quarterly for the rest | Continuous enrichment – monthly, and it stops on a property someone edited | Sales Navigator Advanced Plus data validation; otherwise a re-check (below) |
Pipeline and ownership
| Job | Who | How often | HubSpot | Salesforce |
|---|---|---|---|---|
| Give every record and deal an owner | Sales ops | Weekly | A saved view filtered on owner "is unknown" (all plans); the "Rotate record to owner" workflow action on Sales Hub or Service Hub Professional or Enterprise | A report on records owned by inactive users or a default owner, and leads still sitting in a queue – then reassign; lead assignment rules for new leads |
| Keep deal stages, close dates and next steps current | Each rep, checked by their manager | Weekly | A saved view of open deals whose close date "is before" today | A report on open opportunities with a close date before today; validation rules on stage changes |
| Retire inactive records | RevOps | Quarterly | Set inactive contacts as non-marketing (Marketing Hub Starter and up); delete what your retention rule says to delete – a normal delete can be restored for 90 days | Delete per your retention rule |
Governance
| Job | Who | How often | HubSpot | Salesforce |
|---|---|---|---|---|
| Agree standards and who owns each field; keep history | RevOps lead, with field owners | Quarterly | A field-ownership list (below); property history on every plan | The same list; field history tracking – 20 fields per object, 18 months in the UI and 24 via the API (Field Audit Trail tracks up to 200) |
Sources: HubSpot's knowledge base on duplicates, data quality tools, required fields on record creation, property field types, enrichment settings, saved views, workflow actions, non-marketing contacts, restoring deleted records and property history; Salesforce Help on duplicate management, validation rules, lead assignment rules and field history tracking.
Why contact data goes stale
People change jobs, titles and cities, and nobody tells your CRM. The numbers that hold up:
- Tenure. The median US employee had been with their current employer 4.1 years in January 2026, and 20.6% of wage and salary workers had been there a year or less (US Bureau of Labor Statistics, September 2026). Among 25- to 34-year-olds the median is 3.0 years. That is tenure context, not a CRM decay rate – but it tells you a contact list of buyers will not stay put.
- Email. ZeroBounce's 2026 report on customer lists it verified found that "at least 23% of an email list decays within just one year", with job changes among the causes (ZeroBounce).
Two figures circulate more than these, and neither means what it is quoted for. "B2B data decays 70.3% a year" traces to a 2009 survey of business-card holders reported in a 2015 Biznology post, where 29.6% had changed companies and many more had changed some field. And "CRM data decays 34% a year" is Validity's 2022 respondents estimating how much their data would degrade if their company did not invest in quality – a survey estimate, not a measured rate.
Who owns each field
Most overwrite damage comes from two sources writing the same field with no rule about who wins. Decide it once, field by field:
| Field | Source of truth | May an automated source overwrite it? | Re-check |
|---|---|---|---|
| Form, rep or verification service | No – verify, then replace | Before campaigns | |
| Phone | Rep or data vendor | Review first | When you use it |
| Job title | The person's current profile, as observed, and your enrichment | Review – unless the field was never edited and enrichment owns it | Monthly for active records |
| Company | The same as job title | Review – a move is confirmed in two steps: observe, then confirm | Monthly for active records |
| Location | Enrichment or the profile | Review | Quarterly |
| Lifecycle stage, owner | Your CRM workflows | Never from an external source | – |
HubSpot already works this way for its own enrichment: "Automatic enrichment doesn't overwrite values set by a user or another system" (HubSpot). The table extends that rule to every source you add.
Keep titles and companies current
Start with what your CRM and LinkedIn plan already do
- HubSpot continuous enrichment. It is available on Starter, Professional and Enterprise Hubs, and updates "previously enriched records monthly when new data becomes available". Each property can fill empty values only, overwrite, or stay untouched (HubSpot). Two limits matter:
- if a user or another system edits an enriched value, continuous enrichment "stops enriching that property";
- conversational enrichment and the workflow "Enrich record" action can overwrite properties regardless of those settings;
- its data comes from HubSpot's own dataset, so people who are not in it are not refreshed.
- Sales Navigator Advanced Plus. With CRM Sync turned on for HubSpot, Microsoft Dynamics, Oracle or Salesforce:
- CRM badges flag out-of-date records (LinkedIn Help);
- on Salesforce and Dynamics only, the original data validation writes a "Not at Company" flag to the contact, on a daily or weekly schedule the admin sets (LinkedIn Help);
- "Update Leads and Contacts" fills the fields from the LinkedIn profile, ticks the mismatched ones and waits for the rep to confirm. It allows up to 1,000 updated contacts per user per month, and an admin must turn it on (LinkedIn Help);
- Relationship Maps let you select and update CRM records in bulk (LinkedIn Help);
- Advanced Plus is priced by quote – see Sales Navigator pricing.
- Sales Navigator Core and Advanced. The "Changed jobs" lead filter covers the last 90 days (LinkedIn Help), and saved-lead alerts tell you when "A saved lead has moved to a new company" (LinkedIn Help). Both are useful to a rep, but neither writes back to the CRM.
- Hosted refresh workflows.
- Airtop's HubSpot template reads a list you choose, runs it through a B2B enrichment service, and updates title and company automatically – about $1.50 per 1,000 contacts scanned, by Airtop's own tests. It is the simpler choice when you trust the vendor's data and want no review step.
- Clay tracks job changes for contacts by their LinkedIn URL.
- PhantomBuster publishes a weekly LinkedIn refresh workflow that updates changed fields.
Re-check against saved LinkedIn profiles, with a review queue
The routes above leave gaps. Some fields stop refreshing because someone edited them, some people are not in the vendor's dataset, and Salesforce teams without Advanced Plus get no write-back. You may also simply want every change reviewed and the old value kept.
For those records, use the person's own LinkedIn profile as an observation. It is something the member maintains, not proof of their current job, so it goes to review rather than straight into the field.
- Pick the records worth re-checking. Open opportunities, customers' champions and contacts in active sequences – not the whole database.
- Fetch each saved LinkedIn profile URL.
- Compare title, company and location with what the CRM holds.
- Classify each record (table below).
- Write a review row with the stored value, the observed value and the date you observed it.
- Approve, then write back through your CRM's import or API – or an n8n or Make workflow, which have their own HubSpot and Salesforce modules.
- Keep the old value. HubSpot's property history keeps it on every plan, and so does Salesforce field history for tracked fields – or copy it to a "previous company" field.
| Status | What it means | What to do |
|---|---|---|
match | Title, company and location agree | Nothing; record the check date |
changed | Same company, but a different title or location | Review the listed fields and update |
possible move | The stored company is not among the profile's current roles | Confirm before you move the contact |
still at company, different primary role | The profile lists several current roles; the stored company is one of them | Review the title only |
unknown | No saved URL, or the profile could not be fetched | Fix the URL or retry – never read it as "no change" |
Linked API fetches the profile through your own LinkedIn account and returns the current position, company, location and, if you ask, the experience list. The script reads contacts.json – an export with id, linkedinUrl, title, company and location – and writes only the rows that need review. It changes nothing in your CRM.
import { readFile, writeFile } from "node:fs/promises";
import LinkedApi, { LinkedApiError } from "@linkedapi/node";
const linkedapi = new LinkedApi({
linkedApiToken: "your-linked-api-token",
identificationToken: "your-identification-token",
});
interface CrmContact {
id: string;
linkedinUrl: string | null;
title: string;
company: string;
location: string;
}
type ReviewStatus =
| "match"
| "changed"
| "possible move"
| "still at company, different primary role"
| "unknown";
interface ReviewRow {
contactId: string;
status: ReviewStatus;
changedFields: Array<string>;
stored: { title: string; company: string; location: string };
observed: { title: string; company: string; location: string } | null;
observedAt: string;
note: string;
}
function normalize(value: string): string {
return value
.normalize("NFKC")
.toLowerCase()
.replace(/\b(inc|llc|ltd|gmbh|corp)\b/g, "")
.replace(/[^\p{L}\p{N}]/gu, "");
}
function isSameCompany(observed: string, stored: string): boolean {
const normalized = normalize(observed);
return normalized !== "" && normalized === normalize(stored);
}
async function recheck(contact: CrmContact): Promise<ReviewRow> {
const stored = { title: contact.title, company: contact.company, location: contact.location };
const row: ReviewRow = {
contactId: contact.id,
status: "unknown",
changedFields: [],
stored,
observed: null,
observedAt: new Date().toISOString(),
note: "",
};
if (!contact.linkedinUrl) {
row.note = "No saved LinkedIn URL";
return row;
}
let person;
try {
const workflow = await linkedapi.fetchPerson.execute({
personUrl: contact.linkedinUrl,
retrieveExperience: true,
});
const { data, errors } = await linkedapi.fetchPerson.result(workflow.workflowId);
if (errors.length > 0 || !data) {
row.note = errors.map((error) => error.message).join("; ");
return row;
}
person = data;
} catch (error) {
if (error instanceof LinkedApiError) {
row.note = error.message;
return row;
}
throw error;
}
row.observed = { title: person.position, company: person.companyName, location: person.location };
const currentCompanies = [
person.companyName,
...(person.experiences ?? [])
.filter((experience) => experience.endTime === null)
.map((experience) => experience.companyName),
];
const isAtStoredCompany = currentCompanies.some((company) =>
isSameCompany(company, contact.company),
);
if (!isAtStoredCompany) {
row.status = "possible move";
} else if (!isSameCompany(person.companyName, contact.company)) {
row.status = "still at company, different primary role";
} else {
if (normalize(person.position) !== normalize(contact.title)) row.changedFields.push("title");
if (normalize(person.location) !== normalize(contact.location)) row.changedFields.push("location");
row.status = row.changedFields.length > 0 ? "changed" : "match";
}
return row;
}
async function main(): Promise<void> {
const contacts: Array<CrmContact> = JSON.parse(await readFile("contacts.json", "utf8"));
const review: Array<ReviewRow> = [];
for (const contact of contacts) {
const row = await recheck(contact);
if (row.status !== "match") {
review.push(row);
}
}
await writeFile("review.json", JSON.stringify(review, null, 2));
console.log(`${review.length} of ${contacts.length} contacts need review`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});How to read it:
- A failed fetch is
unknown, not "no change". If the URL is missing, the action fails, or the SDK raises an error such as a timeout, the row keeps the message, goes to review, and the run moves on to the next contact. - Several current roles are checked before anyone is "moved". The experience list shows every role without an end date. If the stored company is still among them, the contact is not flagged as having left.
- Company names are compared loosely. Case, punctuation and suffixes such as Inc or Ltd are ignored; letters in any script count, and an empty value never counts as a match. Rebrands and acquisitions will still show up as
possible move, which is why that status means "confirm", not "update". - Each re-check is one profile view on your account. It runs at a human pace and within the per-account profile-view limit you set (admin limits). The person may see that you viewed their profile, unless you browse in private mode. So re-check the records that matter, on a cadence, rather than the whole database every week.
- Fetching a profile by URL and the fields it returns are covered in the LinkedIn profile scraper guide.
From the shell, linkedin person fetch <url> --experience returns the same data for a spot check (CLI).
With an AI agent. An agent with the MCP server or the agent-friendly CLI can run the same check from a plain ask – how an agent gets access to your account is covered in the LinkedIn AI agent guide. An illustrative prompt:
For the contacts on my open deals, re-check each saved LinkedIn profile, compare title and company with the CRM, and list the mismatches for my review. Change nothing.
The schedule and the review queue belong to you – your CRM, your scheduler or your agent's host. Linked API returns the observation; it does not decide what your CRM should say.
How to audit CRM data quality
A quarterly audit takes an afternoon and tells you which row of the checklist to fix first:
- Fill rates. What share of active records have the fields you route and report on – owner, industry, lifecycle stage, title? HubSpot's data quality tools show property fill rates; in Salesforce, a report on blank values does the same.
- Duplicates. How many duplicate pairs your tools flag, and whether the count is falling.
- Verification dates. What share of records carries a "last verified" date, and how old it is.
- Sample mismatch rate. Re-check a sample of 100 active contacts against their LinkedIn profiles and count the
changedandpossible moverows. That share includes spelling differences and moves you have not confirmed yet. To see how fast your data decays, re-check the same verified records again after a set interval, say a quarter. - Bounces. Hard-bounce rate on your last campaigns – the email column's version of the same number.
Frequently Asked Questions (FAQ)
Continuously at entry – required fields, approved values, duplicate checks on import – and on a schedule for the rest. Weekly is a sensible default for owners and open deals, monthly for refreshing active contacts, and quarterly for audits and retiring inactive records.
People change jobs, titles, cities and email addresses, and their records do not follow. Imports, duplicates and fields written by two sources without an ownership rule add to it. The median US employee has been with their employer 4.1 years, so a list of buyers changes steadily even when nobody touches it.
Use the data quality tools for duplicates and formatting, required properties on the create-record form, saved views for records without an owner, and enrichment set to fill empty values. Continuous enrichment refreshes the values it filled every month, but it stops on any property someone edited – re-check those separately.
Use matching and duplicate rules, required fields, picklists and validation rules, lead assignment rules, and field history tracking on the fields that matter. Sales Navigator Advanced Plus adds a "Not at Company" flag; without it, re-check job fields against another source and review the changes.
For hygiene, re-check saved LinkedIn profiles on a schedule and send possible move rows to review, as above. Sales Navigator alerts cover saved leads and Advanced Plus flags CRM contacts. To act on a job change as a sales signal – a champion who moved to a new account – see LinkedIn buying signals.
Only the fields that source owns and that nobody has edited since. For everything else, let the new value go to review and keep the old one, which is the rule HubSpot applies to its own automatic enrichment.
Build it in, or hand it to an agent
Build the re-check into your own CRM sync with the REST API, the Node and Python SDKs or the shell CLI, or run it from n8n or Make. Or have an AI agent run it out of the box through the MCP server, the agent-friendly CLI or a ready-made skill, on your own LinkedIn account. Plans are flat per seat, from $49/mo billed annually – see pricing.
Facts verified October 30, 2026 – HubSpot, Salesforce and LinkedIn help pages, the BLS tenure release, and the competitor workflows named here checked on that date.