De-duplicate and merge your Gmail contacts
Find near-duplicate contacts in Google Contacts and consolidate them into one canonical record.
Published Feb 10, 2026
Google Contacts accumulates duplicates without anyone meaning to create them. A phone sync adds one record, a CSV import adds another, an autocomplete from an old email adds a third — and after a few years the same person exists three times over, each copy holding a different scrap of detail.
After a decade of email, Awadesh had exactly this: two or three records for half his clients. This script collapses the mess. It groups contacts by their primary email address, and for any address with more than one record it builds a single merged contact that keeps every distinct name, email, phone number and organisation across the group. Crucially, it runs in a dry-run mode by default — it logs what it would merge so you can check the proposals before any contact is actually changed or deleted.
What you’ll need
- The People API enabled as an advanced service: in the Apps Script editor open Services, add People API, and confirm. This also requires the People API to be on for the Google Cloud project behind the script.
- A few minutes to review the dry-run log before enabling the live merge — this script changes your real contacts, so the review step is not optional.
- Nothing else — it reads and writes Google Contacts directly through the API.
The script
// Contact fields to read, merge and write back.
const PERSON_FIELDS = 'names,emailAddresses,phoneNumbers,organizations';
// How many contacts to request in one page. 1000 is the API maximum.
const PAGE_SIZE = 1000;
// Dry run by default: log proposed merges without changing anything.
// Flip to true only after you have reviewed the log.
const APPLY_CHANGES = false;
/**
* Groups contacts by primary email and, for any email with more than one
* record, builds a merged contact. Logs proposed merges in dry-run mode, or
* applies them when APPLY_CHANGES is true.
*/
function dedupeContacts() {
// 1. Read the contact list. searchContacts with an empty query returns the
// full address book up to pageSize.
const results = People.People.searchContacts({
query: '',
readMask: PERSON_FIELDS,
pageSize: PAGE_SIZE,
}).results || [];
if (!results.length) {
console.log('No contacts returned — nothing to do.');
return;
}
// 2. Bucket every contact by its lower-cased primary email address.
const byEmail = new Map();
for (const result of results) {
const person = result.person;
const email = (person.emailAddresses || [])[0]?.value?.toLowerCase();
if (!email) continue; // skip contacts with no email to key on
if (!byEmail.has(email)) byEmail.set(email, []);
byEmail.get(email).push(person);
}
// 3. For each email with more than one record, build a merged contact.
let groups = 0;
for (const [email, group] of byEmail) {
if (group.length < 2) continue; // not a duplicate
groups++;
const merged = mergeRecords(group);
console.log('Merge ' + group.length + ' → 1 for ' + email
+ ': ' + (merged.names?.[0]?.displayName || '(no name)'));
// 4. Only touch real contacts once APPLY_CHANGES has been turned on.
if (APPLY_CHANGES) {
People.People.updateContact(
merged,
group[0].resourceName,
{ updatePersonFields: PERSON_FIELDS },
);
group.slice(1).forEach((g) =>
People.People.deleteContact(g.resourceName));
}
}
console.log((APPLY_CHANGES ? 'Merged ' : 'Found ') + groups
+ ' duplicate group(s).');
}
/**
* Combines a group of duplicate contacts into one record, keeping the first
* name and every distinct email, phone number and organisation across them.
*/
function mergeRecords(group) {
// Collect a field across all records, dropping exact duplicate entries.
const pick = (key) => group
.flatMap((p) => p[key] || [])
.filter(Boolean)
.filter((v, i, a) =>
a.findIndex((x) => JSON.stringify(x) === JSON.stringify(v)) === i);
return {
names: pick('names').slice(0, 1), // keep a single display name
emailAddresses: pick('emailAddresses'),
phoneNumbers: pick('phoneNumbers'),
organizations: pick('organizations'),
};
}
How it works
dedupeContactscalls the People API’ssearchContactswith an empty query, which returns the address book up to the page size. If nothing comes back it logs a message and stops.- It buckets every contact into a
Mapkeyed by the lower-cased value of the contact’s first email address. Contacts with no email are skipped, since there is nothing reliable to group them on. - For any email that maps to two or more contacts, it calls
mergeRecordsto build a single combined record. mergeRecordsuses thepickhelper to gather a field — names, emails, phones, organisations — across every record in the group, dropping exact duplicates by comparing their JSON. It keeps a single display name but preserves every distinct email, phone and organisation.- If
APPLY_CHANGESisfalse(the default), it only logs the proposed merge. If it istrue, it writes the merged record onto the first contact in the group and deletes the rest.
Example run
Suppose three contacts share the email [email protected]:
| Record | Name | Phones | Organisation |
|---|---|---|---|
| 1 (phone sync) | Priya Shah | +44 7700 900111 | — |
| 2 (CSV import) | Priya Shah | — | Acme Ltd |
| 3 (autocomplete) | P. Shah | +44 7700 900111 | — |
A dry-run logs:
Merge 3 → 1 for [email protected]: Priya Shah
Found 1 duplicate group(s).
With APPLY_CHANGES set to true, the first record becomes the canonical
contact — name “Priya Shah”, phone +44 7700 900111, organisation “Acme Ltd” —
and records 2 and 3 are deleted. The duplicate phone number from record 3 is
discarded because it is identical to the one already kept.
Run it
This is an occasional clean-up, run by hand:
- Leave
APPLY_CHANGESset tofalse. - In the Apps Script editor, select
dedupeContactsand click Run, then approve the authorisation prompt. - Read the execution log carefully. Each line shows a proposed merge — make sure none of them are pairing genuinely different people.
- Only when the log looks right, set
APPLY_CHANGEStotrueand run it once more to apply the merges.
Watch out for
- Always dry-run first. The default
APPLY_CHANGES = falseexists for a reason —deleteContactis permanent, and a bad merge cannot be undone from the script. - Merging is keyed on the first email only. Two records for the same person that happen to list different primary emails will not be grouped, and two different people who share a shared inbox address would be grouped wrongly. Scan the log for both cases.
- This page only reads the first 1,000 contacts. For a larger address book,
follow the
nextPageTokenfromsearchContactsand process the book in pages, storing a cursor so a long run can resume. - The People API has tighter quotas than Gmail. A big address book with many
duplicate groups can hit per-minute write limits — chunk the updates and add
a short
Utilities.sleepbetween them if you see quota errors. - The merged record keeps only the first display name in the group. If the
“best” name is on a later record, reorder the group or refine
mergeRecordsbefore applying changes.
Related
Convert long email threads into a summary note
Collapse a thread's history into a Doc for handover — perfect for client transitions or vacation cover.
Updated Jun 6, 2026
Pull event RSVPs from emails into a Sheet
Parse yes/no replies to event invites and tally attendance automatically.
Updated Jun 2, 2026
Turn forwarded emails into project tasks
Forward to [email protected] and a row lands in the Projects sheet under the right client.
Updated May 30, 2026
Turn starred emails into a task list
Sync every starred thread into the Northwind Tasks sheet automatically.
Updated May 26, 2026
Alert when a label hits a backlog threshold
Warn the Northwind team in Slack when a Gmail label has more than N unread threads.
Updated Mar 31, 2026