Google Apps Script

Clean a contact list and import it into Google Contacts

Shouty names, phone numbers in six formats, the same person three times, rows that just say "n/a". This free script sorts all of that out in a Google Sheet, hands you a Google Contacts CSV, imports it into your account and then checks that every field arrived.

Free · runs in your own Google account · nothing sent to us · about 15 minutes start to finish

Before and after

What "cleaned" means here

Four rows from a typical export, run with the default settings.

Your sheet Goes into Google Contacts as What happened
JOHN SMITH
(415) 555-0123
John Smith
+14155550123
Name case, email case, phone format
Smith, John
415.555.0123 x12
Not imported Same email as row 1, so it is merged into it
dr. maria o'brien-lópez
maria at gmail
020 7946 0958
Maria O'Brien-López
—
+442079460958
Bad email moved to Notes (UK default here)
n/a
—
Not imported Junk row, removed

Four menu items, run in order

Nothing reaches your contacts until step 3, and every step reads the Cleaned tab fresh. If the script gets a name wrong, fix the cell and carry on.

  1. Contact Cleaner → 1. Clean & review

    Clean the list, without touching your contacts

    • Finds your columns by name: "Full Name", "First", "Surname", "Mobile", "E-mail", "Company" and the usual variations
    • Splits full names, including "Smith, Jane", and drops Mr / Mrs / Dr
    • Fixes names typed in ALL CAPS or all lower case, and leaves "McKenzie" and "DeShawn" alone
    • Turns phone numbers into one international format, adding your country code where it is missing
    • Removes duplicates and merges their details into the row it keeps
    • Drops rows with nothing to contact, plus "n/a", "test" and noreply@ junk

    You get: A "Cleaned" tab you can edit by hand, and a "Removed" tab that says why each row went.

  2. Contact Cleaner → 2. Save Google Contacts CSV

    Save the file for your records

    • Writes a CSV using the column headers Google Contacts imports: First Name, Last Name, E-mail 1 - Value, Phone 1 - Value, Organization Name…
    • Adds your label, so the batch is easy to find later
    • Saves it to your Google Drive and gives you the link

    You get: A clean CSV you can keep, send on, or import by hand at contacts.google.com.

  3. Contact Cleaner → 3. Import into Google Contacts

    Add them to your account

    • Asks you to confirm the count and the account first
    • Skips anyone whose email or phone is already in your contacts
    • Creates contacts 200 at a time through Google's People API and applies your label
    • Writes "Imported" and the contact ID next to each row, so a second run carries on where the first stopped

    You get: Contacts in your account, with every row on the sheet marked Imported, Skipped or Failed.

  4. Contact Cleaner → 4. Check the import

    Confirm everything arrived

    • Reads every imported contact back out of Google Contacts
    • Compares name, emails, phones and company with the sheet
    • Lists anything missing or different on an "Import check" tab

    You get: "312 of 312 imported contacts match the sheet exactly", or a list of what to fix.

Your rules

Set how your list gets cleaned

Your choices go into the settings block at the top of the script, and the script below updates as you change them. You can also edit that block later in Apps Script.

Code.gs
/**
 * Contact Cleaner for Google Contacts
 * Cleans a contact list in this spreadsheet, saves a Google Contacts CSV,
 * imports it into your Google Contacts, then checks what arrived.
 *
 * Setup: Services (+) > People API > Add. Then reload the spreadsheet
 * and use the "Contact Cleaner" menu.
 * Guide: https://www.makeinfo.co/resources/clean-import-google-contacts
 */
const CONFIG = {
  sheetName: "Contacts",   // tab that holds your raw list
  nameCase: "title",          // 'title' fixes ALL CAPS / all lower names, 'keep' leaves them
  defaultCountryCode: "1",  // added to numbers written without one
  phoneFormat: "e164",        // 'e164' = +14155550123, 'keep' = as typed
  dedupeBy: "email_or_phone",   // 'email' | 'phone' | 'email_or_phone' | 'name_phone' | 'none'
  mergeDuplicates: true,     // copy missing details from a duplicate into the row that is kept
  keepIf: "name_and_contact",  // 'name_and_contact' | 'email_or_phone' | 'email'
  invalidEmail: "blank",      // 'blank' keeps the contact without it, 'drop' removes the row
  removeJunk: true,          // clears "n/a", "test", noreply@ and example.com values
  label: "Imported contacts",  // Google Contacts label for everything imported ('' for none)
  skipExisting: true,        // don't import people whose email or phone is already in your contacts
};

// Column names the script recognises in your contact tab (case and spacing ignored).
const HEADER_ALIASES = {
  fullName: ['name', 'full name', 'contact', 'contact name', 'display name'],
  firstName: ['first name', 'first', 'firstname', 'given name', 'forename'],
  lastName: ['last name', 'last', 'lastname', 'surname', 'family name'],
  email: ['email', 'e-mail', 'e mail', 'email address', 'e-mail address', 'mail', 'e-mail 1 - value'],
  phone: ['phone', 'phone number', 'mobile', 'mobile phone', 'cell', 'telephone', 'tel', 'phone 1 - value'],
  company: ['company', 'organization', 'organisation', 'organization name', 'business', 'employer'],
  jobTitle: ['job title', 'title', 'position', 'role', 'organization title'],
  notes: ['notes', 'note', 'comments', 'comment'],
};

const CLEAN_TAB = 'Cleaned';
const REMOVED_TAB = 'Removed';
const CHECK_TAB = 'Import check';
const CLEAN_HEADERS = ['First Name', 'Last Name', 'Email', 'Email 2', 'Phone', 'Phone 2',
  'Company', 'Job Title', 'Notes', 'Source Row', 'Status', 'Contact ID'];
const COL = {};
CLEAN_HEADERS.forEach(function (h, i) { COL[h] = i; });

const JUNK_VALUES = ['n/a', 'na', 'none', 'null', 'unknown', 'test', '-', '--', '.', '?', 'x', 'tbd', 'no name'];
const JUNK_EMAIL = /^(no-?reply|do-?not-?reply|test)@|@(example|test)\.(com|org|net)$/i;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i;
const PREFIXES = /^(mr|mrs|ms|miss|mx|dr|prof)\.?\s+/i;

function onOpen() {
  SpreadsheetApp.getUi().createMenu('Contact Cleaner')
    .addItem('1. Clean & review', 'cleanContacts')
    .addItem('2. Save Google Contacts CSV', 'exportCsv')
    .addItem('3. Import into Google Contacts', 'importContacts')
    .addItem('4. Check the import', 'verifyImport')
    .addToUi();
}

// ---------------------------------------------------------------- 1. Clean

function cleanContacts() {
  const ss = SpreadsheetApp.getActive();
  const sheet = ss.getSheetByName(CONFIG.sheetName);
  if (!sheet) throw new Error('No tab called "' + CONFIG.sheetName + '". Rename your contact tab or change CONFIG.sheetName.');

  const values = sheet.getDataRange().getDisplayValues();
  if (values.length < 2) throw new Error('The contact tab needs a header row and at least one contact.');
  const cols = mapHeaders_(values[0]);
  if (cols.email < 0 && cols.phone < 0) {
    throw new Error('Could not find an email or phone column. Headers found: ' + values[0].join(', '));
  }

  const kept = [];
  const removed = [];
  const seen = {}; // dedupe key -> index in kept

  for (let i = 1; i < values.length; i++) {
    const row = values[i];
    if (row.every(function (v) { return String(v).trim() === ''; })) continue;

    const c = normalizeRow_(row, cols);
    c.sourceRow = i + 1;

    const problem = unusableReason_(c);
    if (problem) {
      removed.push([c.sourceRow, problem].concat(row));
      continue;
    }

    const keys = dedupeKeys_(c);
    const hit = keys.filter(function (k) { return k in seen; })[0];
    if (hit) {
      const original = kept[seen[hit]];
      if (CONFIG.mergeDuplicates) mergeInto_(original, c);
      keys.forEach(function (k) { if (!(k in seen)) seen[k] = seen[hit]; });
      removed.push([c.sourceRow, 'Duplicate of row ' + original.sourceRow + (CONFIG.mergeDuplicates ? ' (details merged)' : '')].concat(row));
      continue;
    }
    keys.forEach(function (k) { seen[k] = kept.length; });
    kept.push(c);
  }

  writeTab_(CLEAN_TAB, CLEAN_HEADERS, kept.map(function (c) {
    return [c.first, c.last, c.emails[0] || '', c.emails[1] || '', c.phones[0] || '', c.phones[1] || '',
      c.company, c.jobTitle, c.notes.join('\n'), c.sourceRow, 'Ready', ''];
  }));
  const removedHeaders = ['Source Row', 'Reason'].concat(values[0]);
  writeTab_(REMOVED_TAB, removedHeaders, removed.map(function (r) {
    while (r.length < removedHeaders.length) r.push('');
    return r.slice(0, removedHeaders.length);
  }));

  ss.getSheetByName(CLEAN_TAB).activate();
  SpreadsheetApp.getUi().alert(
    'Kept ' + kept.length + ' contacts, removed ' + removed.length + '.\n\n' +
    'Check the "' + CLEAN_TAB + '" tab and fix anything by hand. The "' + REMOVED_TAB + '" tab says why each row was dropped.\n\n' +
    'Nothing has been added to Google Contacts yet.');
}

function mapHeaders_(headerRow) {
  const normalized = headerRow.map(function (h) {
    return String(h).toLowerCase().replace(/[_\s]+/g, ' ').trim();
  });
  const cols = {};
  Object.keys(HEADER_ALIASES).forEach(function (field) {
    cols[field] = -1;
    HEADER_ALIASES[field].some(function (alias) {
      const idx = normalized.indexOf(alias);
      if (idx >= 0) cols[field] = idx;
      return idx >= 0;
    });
  });
  return cols;
}

function normalizeRow_(row, cols) {
  const get = function (field) { return cols[field] >= 0 ? cleanText_(row[cols[field]]) : ''; };
  const c = { emails: [], phones: [], notes: [] };

  let first = get('firstName');
  let last = get('lastName');
  if (!first && !last && get('fullName')) {
    const split = splitName_(get('fullName'));
    first = split.first;
    last = split.last;
  }
  c.first = formatName_(first.replace(PREFIXES, ''));
  c.last = formatName_(last);
  c.company = get('company');
  c.jobTitle = get('jobTitle');
  if (get('notes')) c.notes.push(get('notes'));

  // A cell may hold several emails or phones separated by ; , / or spaces
  const emailParts = [];
  get('email').split(/[;,\/]+/).forEach(function (part) {
    const words = part.trim().split(' ');
    const allEmails = words.every(function (w) { return w.indexOf('@') >= 0; });
    // "[email protected] [email protected]" is two emails; "jane @ gmail.com" is one email with stray spaces
    if (allEmails) words.forEach(function (w) { emailParts.push([w, w]); });
    else emailParts.push([words.join(''), part.trim()]);
  });
  emailParts.forEach(function (pair) {
    const raw = pair[1];
    const email = pair[0].toLowerCase().replace(/^mailto:/, '').replace(/^[<(]|[>).]$/g, '');
    if (!email) return;
    if (CONFIG.removeJunk && JUNK_EMAIL.test(email)) return;
    if (EMAIL_RE.test(email)) addUnique_(c.emails, email);
    else c.badEmail = c.badEmail || raw;
  });
  if (c.badEmail && CONFIG.invalidEmail === 'blank') c.notes.push('Unreadable email in source: ' + c.badEmail);

  get('phone').split(/[;,\/]+/).forEach(function (raw) {
    if (!raw.trim()) return;
    const phone = formatPhone_(raw);
    if (phone) addUnique_(c.phones, phone);
    else c.notes.push('Unreadable phone in source: ' + raw.trim());
  });

  // Google Contacts gets two of each here; anything beyond that is kept in Notes
  c.emails.splice(2).forEach(function (e) { c.notes.push('Other email: ' + e); });
  c.phones.splice(2).forEach(function (p) { c.notes.push('Other phone: ' + p); });
  return c;
}

function cleanText_(value) {
  const s = String(value).replace(/[​-‍]/g, '').replace(/\s+/g, ' ').trim();
  if (CONFIG.removeJunk && JUNK_VALUES.indexOf(s.toLowerCase()) >= 0) return '';
  return s;
}

function splitName_(full) {
  // "Smith, Jane" -> Jane / Smith
  if (full.indexOf(',') > 0) {
    const parts = full.split(',');
    return { first: parts.slice(1).join(' ').trim(), last: parts[0].trim() };
  }
  const words = full.replace(PREFIXES, '').split(' ');
  return { first: words[0], last: words.slice(1).join(' ') };
}

function formatName_(name) {
  if (!name || CONFIG.nameCase === 'keep') return name;
  // Only touch names typed in ALL CAPS or all lower case, so "McKenzie" and "DeShawn" survive
  if (name !== name.toUpperCase() && name !== name.toLowerCase()) return name;
  return name.toLowerCase()
    .replace(/(^|[\s\-'’])([a-zÀ-ɏ])/g, function (m, sep, ch) { return sep + ch.toUpperCase(); })
    .replace(/\bMc([a-z])/g, function (m, ch) { return 'Mc' + ch.toUpperCase(); });
}

function formatPhone_(raw) {
  const text = String(raw).trim();
  if (CONFIG.phoneFormat === 'keep') return text;

  const m = text.match(/^(.*?)(?:\s*(?:ext\.?|extension|x|#)\s*(\d{1,6}))?$/i);
  const main = m[1];
  const ext = m[2];
  let digits = main.replace(/\D/g, '');
  const international = /^\s*\+/.test(main) || /^\s*00/.test(main);

  if (/^\s*00/.test(main)) digits = digits.slice(2);
  if (!international) {
    const cc = CONFIG.defaultCountryCode;
    if (cc === '1' && digits.length === 11 && digits.charAt(0) === '1') {
      // US/Canada number already written with its leading 1
    } else {
      digits = cc + digits.replace(/^0+/, ''); // drop the national trunk 0 (UK, IN, AU, DE...)
    }
  }
  if (digits.length < 8 || digits.length > 15) return '';
  return '+' + digits + (ext ? ' ext. ' + ext : '');
}

function unusableReason_(c) {
  if (c.badEmail && CONFIG.invalidEmail === 'drop') return 'Invalid email: ' + c.badEmail;
  const hasName = c.first || c.last || c.company;
  const hasEmail = c.emails.length > 0;
  const hasPhone = c.phones.length > 0;
  if (CONFIG.keepIf === 'email' && !hasEmail) return 'No valid email';
  if (CONFIG.keepIf === 'email_or_phone' && !hasEmail && !hasPhone) return 'No valid email or phone';
  if (CONFIG.keepIf === 'name_and_contact') {
    if (!hasName) return 'No name or company';
    if (!hasEmail && !hasPhone) return 'No valid email or phone';
  }
  return '';
}

function phoneKey_(phone) {
  return String(phone).split(/ext/i)[0].replace(/\D/g, '').slice(-10);
}

function dedupeKeys_(c) {
  const by = CONFIG.dedupeBy;
  const keys = [];
  if (by === 'email' || by === 'email_or_phone') {
    c.emails.forEach(function (e) { keys.push('e:' + e); });
  }
  if (by === 'phone' || by === 'email_or_phone') {
    c.phones.forEach(function (p) { keys.push('p:' + phoneKey_(p)); });
  }
  if (by === 'name_phone' && (c.first || c.last)) {
    const name = (c.first + ' ' + c.last).toLowerCase().trim();
    c.phones.forEach(function (p) { keys.push('np:' + name + '|' + phoneKey_(p)); });
  }
  return keys;
}

function mergeInto_(a, b) {
  ['first', 'last', 'company', 'jobTitle'].forEach(function (f) { if (!a[f] && b[f]) a[f] = b[f]; });
  b.emails.forEach(function (e) {
    if (a.emails.indexOf(e) >= 0) return;
    if (a.emails.length < 2) a.emails.push(e);
    else addUnique_(a.notes, 'Other email: ' + e);
  });
  b.phones.forEach(function (p) {
    if (a.phones.some(function (x) { return phoneKey_(x) === phoneKey_(p); })) return;
    if (a.phones.length < 2) a.phones.push(p);
    else addUnique_(a.notes, 'Other phone: ' + p);
  });
  b.notes.forEach(function (n) { addUnique_(a.notes, n); });
}

function addUnique_(list, value) {
  if (list.indexOf(value) < 0) list.push(value);
}

function writeTab_(name, headers, rows) {
  const ss = SpreadsheetApp.getActive();
  const sheet = ss.getSheetByName(name) || ss.insertSheet(name);
  sheet.clear();
  const all = [headers].concat(rows);
  const range = sheet.getRange(1, 1, all.length, headers.length);
  range.setNumberFormat('@'); // plain text, so +14155550123 stays a phone number and not a formula
  range.setValues(all);
  sheet.getRange(1, 1, 1, headers.length).setFontWeight('bold');
  sheet.setFrozenRows(1);
  return sheet;
}

// Reads the Cleaned tab back, including any fixes you made by hand
function readCleaned_() {
  const sheet = SpreadsheetApp.getActive().getSheetByName(CLEAN_TAB);
  if (!sheet) throw new Error('Run "1. Clean & review" first.');
  const values = sheet.getDataRange().getDisplayValues();
  return values.slice(1).map(function (r, i) {
    return {
      row: i + 2,
      first: r[COL['First Name']].trim(),
      last: r[COL['Last Name']].trim(),
      emails: [r[COL['Email']], r[COL['Email 2']]].map(String).map(function (s) { return s.trim(); }).filter(String),
      phones: [r[COL['Phone']], r[COL['Phone 2']]].map(String).map(function (s) { return s.trim(); }).filter(String),
      company: r[COL['Company']].trim(),
      jobTitle: r[COL['Job Title']].trim(),
      notes: r[COL['Notes']].trim(),
      status: r[COL['Status']],
      contactId: r[COL['Contact ID']],
    };
  }).filter(function (c) {
    return c.first || c.last || c.company || c.emails.length || c.phones.length;
  });
}

// ---------------------------------------------------------------- 2. CSV

function exportCsv() {
  const contacts = readCleaned_();
  const headers = ['First Name', 'Last Name', 'Organization Name', 'Organization Title', 'Notes', 'Labels',
    'E-mail 1 - Value', 'E-mail 2 - Value', 'Phone 1 - Value', 'Phone 2 - Value'];
  const labels = CONFIG.label ? CONFIG.label + ' ::: * myContacts' : '* myContacts';
  const lines = [headers].concat(contacts.map(function (c) {
    return [c.first, c.last, c.company, c.jobTitle, c.notes, labels,
      c.emails[0] || '', c.emails[1] || '', c.phones[0] || '', c.phones[1] || ''];
  })).map(function (fields) {
    return fields.map(function (f) { return '"' + String(f).replace(/"/g, '""') + '"'; }).join(',');
  });

  const stamp = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd HHmm');
  const file = DriveApp.createFile('Google Contacts import ' + stamp + '.csv', lines.join('\r\n'), MimeType.CSV);
  showLink_('CSV saved to your Google Drive',
    contacts.length + ' contacts, in the column layout Google Contacts imports.', file.getUrl(), 'Open the CSV');
}

// ---------------------------------------------------------------- 3. Import

function importContacts() {
  requirePeopleApi_();
  const ui = SpreadsheetApp.getUi();
  const contacts = readCleaned_().filter(function (c) { return c.status !== 'Imported'; });
  if (!contacts.length) {
    ui.alert('Nothing left to import. Every row on the "' + CLEAN_TAB + '" tab is already marked Imported.');
    return;
  }
  const answer = ui.alert('Import ' + contacts.length + ' contacts?',
    'They will be added to the Google Contacts of ' + Session.getActiveUser().getEmail() +
    (CONFIG.label ? ' with the label "' + CONFIG.label + '"' : '') + '.', ui.ButtonSet.OK_CANCEL);
  if (answer !== ui.Button.OK) return;

  const existing = CONFIG.skipExisting ? existingContactKeys_() : {};
  const sheet = SpreadsheetApp.getActive().getSheetByName(CLEAN_TAB);
  const groupName = CONFIG.label ? findOrCreateGroup_(CONFIG.label) : '';
  const toCreate = [];
  let skipped = 0;

  contacts.forEach(function (c) {
    const already = c.emails.some(function (e) { return existing['e:' + e.toLowerCase()]; }) ||
      c.phones.some(function (p) { return existing['p:' + phoneKey_(p)]; });
    if (already) {
      setStatus_(sheet, c.row, 'Skipped: already in Google Contacts', '');
      skipped++;
    } else {
      toCreate.push(c);
    }
  });

  let created = 0;
  let failed = 0;
  for (let i = 0; i < toCreate.length; i += 200) {
    const chunk = toCreate.slice(i, i + 200);
    try {
      const res = withRetry_(function () {
        return People.People.batchCreateContacts({
          contacts: chunk.map(function (c) { return { contactPerson: toPerson_(c) }; }),
          readMask: 'names,emailAddresses,phoneNumbers',
        });
      });
      const ids = matchCreated_(chunk, res.createdPeople || []);
      chunk.forEach(function (c, j) {
        if (ids[j]) { setStatus_(sheet, c.row, 'Imported', ids[j]); created++; }
        else { setStatus_(sheet, c.row, 'Failed: not returned by Google', ''); failed++; }
      });
      if (groupName) {
        const added = ids.filter(String);
        if (added.length) {
          withRetry_(function () {
            return People.ContactGroups.Members.modify({ resourceNamesToAdd: added }, groupName);
          });
        }
      }
    } catch (err) {
      chunk.forEach(function (c) { setStatus_(sheet, c.row, 'Failed: ' + err.message, ''); });
      failed += chunk.length;
    }
    SpreadsheetApp.flush(); // progress is saved row by row, so a re-run picks up where this stopped
    Utilities.sleep(1500);
  }

  ui.alert('Import finished',
    created + ' added, ' + skipped + ' skipped (already in your contacts), ' + failed + ' failed.\n\n' +
    'Run "4. Check the import" to confirm every field arrived.', ui.ButtonSet.OK);
}

function toPerson_(c) {
  const p = {};
  if (c.first || c.last) p.names = [{ givenName: c.first, familyName: c.last }];
  if (c.emails.length) p.emailAddresses = c.emails.map(function (v) { return { value: v }; });
  if (c.phones.length) p.phoneNumbers = c.phones.map(function (v) { return { value: v }; });
  if (c.company || c.jobTitle) p.organizations = [{ name: c.company, title: c.jobTitle }];
  if (c.notes) p.biographies = [{ value: c.notes, contentType: 'TEXT_PLAIN' }];
  return p;
}

function fingerprint_(emails, phones, first, last) {
  if (emails.length) return 'e:' + emails[0].toLowerCase();
  if (phones.length) return 'p:' + phoneKey_(phones[0]);
  return 'n:' + (first + ' ' + last).toLowerCase().trim();
}

// Pairs each created person with the sheet row it came from
function matchCreated_(chunk, createdPeople) {
  const queue = {};
  chunk.forEach(function (c, j) {
    const fp = fingerprint_(c.emails, c.phones, c.first, c.last);
    (queue[fp] = queue[fp] || []).push(j);
  });
  const ids = chunk.map(function () { return ''; });
  createdPeople.forEach(function (resp, k) {
    const person = resp.person;
    if (!person || !person.resourceName) return;
    const name = (person.names || [])[0] || {};
    const fp = fingerprint_(
      (person.emailAddresses || []).map(function (e) { return e.value; }),
      (person.phoneNumbers || []).map(function (p) { return p.value; }),
      name.givenName || '', name.familyName || '');
    const j = queue[fp] && queue[fp].length ? queue[fp].shift() : k;
    if (j < ids.length && !ids[j]) ids[j] = person.resourceName;
  });
  return ids;
}

function existingContactKeys_() {
  const keys = {};
  let pageToken;
  do {
    const res = withRetry_(function () {
      return People.People.Connections.list('people/me', {
        personFields: 'emailAddresses,phoneNumbers', pageSize: 1000, pageToken: pageToken,
      });
    });
    (res.connections || []).forEach(function (p) {
      (p.emailAddresses || []).forEach(function (e) { keys['e:' + String(e.value).toLowerCase()] = true; });
      (p.phoneNumbers || []).forEach(function (ph) { keys['p:' + phoneKey_(ph.value)] = true; });
    });
    pageToken = res.nextPageToken;
  } while (pageToken);
  return keys;
}

function findOrCreateGroup_(label) {
  let pageToken;
  do {
    const res = People.ContactGroups.list({ pageSize: 1000, pageToken: pageToken });
    const match = (res.contactGroups || []).filter(function (g) {
      return g.groupType === 'USER_CONTACT_GROUP' && (g.name === label || g.formattedName === label);
    })[0];
    if (match) return match.resourceName;
    pageToken = res.nextPageToken;
  } while (pageToken);
  return People.ContactGroups.create({ contactGroup: { name: label } }).resourceName;
}

function setStatus_(sheet, row, status, contactId) {
  sheet.getRange(row, COL['Status'] + 1, 1, 2).setValues([[status, contactId]]);
}

// ---------------------------------------------------------------- 4. Verify

function verifyImport() {
  requirePeopleApi_();
  const rows = readCleaned_().filter(function (c) { return c.status === 'Imported' && c.contactId; });
  if (!rows.length) {
    SpreadsheetApp.getUi().alert('No imported rows to check yet. Run "3. Import into Google Contacts" first.');
    return;
  }

  const out = [];
  let ok = 0;
  for (let i = 0; i < rows.length; i += 200) {
    const chunk = rows.slice(i, i + 200);
    const res = withRetry_(function () {
      return People.People.getBatchGet({
        resourceNames: chunk.map(function (c) { return c.contactId; }),
        personFields: 'names,emailAddresses,phoneNumbers,organizations',
      });
    });
    const byId = {};
    (res.responses || []).forEach(function (r) { byId[r.requestedResourceName] = r.person; });

    chunk.forEach(function (c) {
      const problems = comparePerson_(c, byId[c.contactId]);
      if (!problems.length) ok++;
      out.push([c.row, (c.first + ' ' + c.last).trim() || c.company, c.contactId,
        problems.length ? problems.join('; ') : 'OK']);
    });
  }

  writeTab_(CHECK_TAB, ['Cleaned Row', 'Name', 'Contact ID', 'Result'], out).activate();
  SpreadsheetApp.getUi().alert(ok + ' of ' + rows.length + ' imported contacts match the sheet exactly.' +
    (ok < rows.length ? '\n\nThe "' + CHECK_TAB + '" tab lists what differs.' : ''));
}

function comparePerson_(c, person) {
  if (!person) return ['Not found in Google Contacts'];
  const problems = [];
  const name = (person.names || [])[0] || {};
  if ((name.givenName || '') !== c.first || (name.familyName || '') !== c.last) problems.push('Name differs');
  const emails = (person.emailAddresses || []).map(function (e) { return String(e.value).toLowerCase(); });
  c.emails.forEach(function (e) { if (emails.indexOf(e.toLowerCase()) < 0) problems.push('Missing email ' + e); });
  const phones = (person.phoneNumbers || []).map(function (p) { return phoneKey_(p.value); });
  c.phones.forEach(function (p) { if (phones.indexOf(phoneKey_(p)) < 0) problems.push('Missing phone ' + p); });
  const org = (person.organizations || [])[0] || {};
  if (c.company && org.name !== c.company) problems.push('Company differs');
  return problems;
}

// ---------------------------------------------------------------- Helpers

function requirePeopleApi_() {
  if (typeof People === 'undefined') {
    throw new Error('Turn on the People API first: in the Apps Script editor, click + next to Services, pick "Peopleapi", then Add.');
  }
}

function withRetry_(fn) {
  for (let attempt = 0; ; attempt++) {
    try {
      return fn();
    } catch (err) {
      const retryable = /429|quota|rate|backend|503|500/i.test(err.message);
      if (!retryable || attempt >= 4) throw err;
      Utilities.sleep(Math.pow(2, attempt) * 2000);
    }
  }
}

function showLink_(title, message, url, linkText) {
  const html = HtmlService.createHtmlOutput(
    '<div style="font-family:Arial,sans-serif;font-size:14px;line-height:1.5">' +
    '<p>' + message + '</p>' +
    '<p><a href="' + url + '" target="_blank" style="color:#0F766E;font-weight:bold">' + linkText + '</a></p>' +
    '<p style="color:#57534E">To import it by hand instead of running step 3: contacts.google.com → Import → pick this file.</p>' +
    '</div>').setWidth(420).setHeight(190);
  SpreadsheetApp.getUi().showModalDialog(html, title);
}

About 550 lines. Your settings are the CONFIG block at the top; the rest is the same for everyone.

How to use it

From copied script to imported contacts

  1. 1

    Put your list in a Google Sheet

    Paste or import it into a tab, with one header row on top. Name the tab to match the "Tab with your contacts" field above ("Contacts" by default). Excel or CSV? File → Import in Google Sheets handles both.

  2. 2

    Open Extensions → Apps Script

    From that spreadsheet, not from script.google.com. This ties the script to the sheet, which is how the menu shows up.

  3. 3

    Paste the script

    Delete the placeholder function in Code.gs, paste what you copied above, and press Save (the disk icon or Ctrl+S).

  4. 4

    Turn on the People API

    In the left sidebar, click + next to Services, choose "Peopleapi" and click Add. Keep the identifier as "People". Steps 1 and 2 work without it; steps 3 and 4 need it.

  5. 5

    Reload the spreadsheet

    Close the script tab and refresh the sheet. A "Contact Cleaner" menu appears next to Help after a few seconds.

  6. 6

    Run 1, look, then 2, 3 and 4

    The first run asks you to authorise the script. Google warns that the app is unverified because you wrote it yourself: click Advanced, then Go to (project name). Check the Cleaned tab before you import anything.

Try it on ten rows first

Copy a handful of rows into a new tab, point the script at it, and run all four steps. It takes two minutes and shows you exactly how your list's quirks get handled before you import the other few hundred.

Which column goes where

The script reads your header row and ignores capitals, spaces and underscores, so "E_MAIL" and "email address" both work. It needs at least an email or a phone column. Columns it doesn't recognise are left out; rename a header to one of these if it should come along.

Your header can be Google Contacts field
Name, Full Name, Contact, Display Name First Name + Last Name (split for you)
First Name, First, Given Name, Forename First Name
Last Name, Last, Surname, Family Name Last Name
Email, E-mail, Email Address, Mail Email (up to two, the rest go to Notes)
Phone, Mobile, Cell, Telephone, Tel Phone (up to two, the rest go to Notes)
Company, Organization, Business, Employer Company
Job Title, Title, Position, Role Job title
Notes, Note, Comments Notes

Questions

Can I just import a CSV at contacts.google.com instead?

Yes, and step 2 gives you a CSV built for exactly that. The Google Contacts importer does no cleaning, though. It takes "JOHN SMITH" and "(415) 555-0123" as written, and it will not skip people you already have. Merge & fix will catch some duplicates afterwards, but only inside Google Contacts. The script does the cleaning before anything reaches your account and can import for you as well.

Does my contact list get sent to Makeinfo?

No. The script runs inside your own Google account, reading your sheet and writing to your contacts through Google's own services. This page only builds the settings block; nothing you type here leaves your browser either.

What permissions does it ask for, and why?

Spreadsheets, to read your list and write the Cleaned, Removed and Import check tabs. Google Drive, to save the CSV. Contacts, through the People API, to import and check. It also shows your email address in the import confirmation, so you can see which account the contacts are going into.

How do I undo an import?

Two ways. In Google Contacts, open the label the script used, select all and delete. Or use Settings (the gear) → Undo changes, which restores your whole contact list to how it was up to 30 days ago. Undo changes also reverts anything else you did in that window, so the label is usually the better option.

I have thousands of contacts. Will it cope?

Cleaning 10,000 rows takes seconds. Importing is slower because Google limits how fast an account can create contacts, and an Apps Script run stops after 6 minutes. If it stops, run step 3 again. Rows already marked Imported are skipped, so it carries on from where it stopped. A Google account holds up to 25,000 contacts in total.

Why E.164 phone numbers like +14155550123?

It is the one format every phone and every app agrees on, and Google Contacts shows it nicely spaced for your region anyway. A number without a country code only works in the country it came from, which is why the script adds yours where it is missing. If your list mixes countries, make sure the foreign numbers already start with + or 00.

What does it treat as a duplicate?

Whatever you picked above. "Same email or same phone" is the safest for most lists. Phones are compared on their last 10 digits, so "+1 415 555 0123" and "(415) 555-0123" match. With merging on, the first row is kept and any email, phone, company or note it is missing is copied over from the duplicate.

It says "Turn on the People API first".

The advanced service is not enabled in the script project. In the Apps Script editor click + next to Services, choose Peopleapi and click Add. Leave the identifier as People, since that is the name the script calls.

Stuck, or need it to do something else?

If the script trips over your list, or you need extra fields, a different CRM, or the whole job done for you, get in touch. Tell us roughly what the list looks like and where it needs to end up.

Contact us

or email [email protected]