appscript.dev
Automation Advanced Gmail

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

  1. dedupeContacts calls the People API’s searchContacts with an empty query, which returns the address book up to the page size. If nothing comes back it logs a message and stops.
  2. It buckets every contact into a Map keyed 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.
  3. For any email that maps to two or more contacts, it calls mergeRecords to build a single combined record.
  4. mergeRecords uses the pick helper 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.
  5. If APPLY_CHANGES is false (the default), it only logs the proposed merge. If it is true, 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]:

RecordNamePhonesOrganisation
1 (phone sync)Priya Shah+44 7700 900111
2 (CSV import)Priya ShahAcme 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:

  1. Leave APPLY_CHANGES set to false.
  2. In the Apps Script editor, select dedupeContacts and click Run, then approve the authorisation prompt.
  3. Read the execution log carefully. Each line shows a proposed merge — make sure none of them are pairing genuinely different people.
  4. Only when the log looks right, set APPLY_CHANGES to true and run it once more to apply the merges.

Watch out for

  • Always dry-run first. The default APPLY_CHANGES = false exists for a reason — deleteContact is 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 nextPageToken from searchContacts and 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.sleep between 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 mergeRecords before applying changes.

Related