Recipes / Recipe 13

Google Sheets: remove duplicates but keep the latest entry (by timestamp)

When someone submits a form twice or a record is updated, keep only the newest row per email or ID: a SORTN formula view, an Excel 365 version, and a tested in-place script.

FormulasApps ScriptCleanup Setup time: 5 minutes

You'll need: A sheet with a key column (e.g. email in B) and a date/time column (e.g. a form Timestamp in A).

Option A: a live 'latest only' view with a formula

Put this in A2 of a new tab (copy the headers into row 1). It sorts newest-first by the timestamp in column A, then keeps the first row it sees for each value in column B. Your responses tab is untouched.

Google Sheets: newest row per email (A = Timestamp, B = Email)Formula
=SORTN(SORT(FILTER('Form Responses 1'!A2:E, 'Form Responses 1'!B2:B<>""), 1, FALSE), 9^9, 2, 2, TRUE)

How it works: SORT(..., 1, FALSE) puts the newest timestamps on top; SORTN with tie mode 2 on column 2 returns only the first row for each distinct email, which is now the newest. 9^9 just means “no row limit”.

Excel 365: same result with SORTBY + XMATCHFormula
=LET(d, SORTBY(A2:E500, A2:A500, -1), k, INDEX(d, , 2), FILTER(d, (XMATCH(k, k)=SEQUENCE(ROWS(k)))*(k<>"")))

Option B: delete the older rows in place with a script

  1. Make a copy of the sheet first. This script rewrites the tab.
  2. Extensions → Apps Script, paste the script, and set sheetName, keyColumn and dateColumn.
  3. Save, reload the sheet, then choose Cleanup → Keep latest row per key.
Code.gsApps Script
/**
 * Remove duplicates but keep the LATEST row for each key, judged by a date column
 * (e.g. a form "Timestamp"), not by position in the sheet. Adds a "Cleanup" menu item.
 */
const LATEST_CONFIG = {
  sheetName: 'Form Responses 1', // tab to clean
  headerRows: 1,
  keyColumn: 2,                  // 1-based column that identifies a person/record (2 = B, e.g. email)
  dateColumn: 1,                 // 1-based column holding the date/time to compare (1 = A, "Timestamp")
  caseInsensitive: true
};

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('Cleanup')
    .addItem('Keep latest row per key', 'keepLatestPerKey')
    .addToUi();
}

/** Turns a cell value into a sortable number. Real dates win; blanks sort as oldest. */
function toTime(v) {
  if (v instanceof Date) return v.getTime();
  if (v === '' || v === null || v === undefined) return -Infinity;
  const t = new Date(v).getTime();
  return isNaN(t) ? -Infinity : t;
}

/** Pure function: one row per key (the newest), original row order preserved. Blank keys are kept. */
function latestRows(rows, keyColumn, dateColumn, caseInsensitive) {
  const best = new Map(); // key -> index of newest row so far
  rows.forEach((row, i) => {
    let key = String(row[keyColumn - 1] === undefined ? '' : row[keyColumn - 1]).trim();
    if (caseInsensitive) key = key.toLowerCase();
    if (key === '') return;
    const prev = best.get(key);
    // ">=" means that on a tie the lower row (added later) wins
    if (prev === undefined || toTime(row[dateColumn - 1]) >= toTime(rows[prev][dateColumn - 1])) best.set(key, i);
  });
  const keep = new Set(best.values());
  return rows.filter((row, i) => {
    const key = String(row[keyColumn - 1] === undefined ? '' : row[keyColumn - 1]).trim();
    return key === '' || keep.has(i);
  });
}

function keepLatestPerKey() {
  const cfg = LATEST_CONFIG;
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName(cfg.sheetName);
  if (!sheet) throw new Error('No tab named ' + cfg.sheetName);
  const lastRow = sheet.getLastRow();
  const lastCol = sheet.getLastColumn();
  if (lastRow <= cfg.headerRows) return;
  const range = sheet.getRange(cfg.headerRows + 1, 1, lastRow - cfg.headerRows, lastCol);
  const rows = range.getValues();
  const kept = latestRows(rows, cfg.keyColumn, cfg.dateColumn, cfg.caseInsensitive);
  const removed = rows.length - kept.length;
  if (removed === 0) { ss.toast('No older duplicates found.'); return; }
  range.clearContent();
  sheet.getRange(cfg.headerRows + 1, 1, kept.length, lastCol).setValues(kept);
  ss.toast('Removed ' + removed + ' older duplicate row(s).');
}

Good to know

Do it yourself

Recipe Club: $12/month

Prefer to set things up yourself? Join the Recipe Club and get access to a private GitHub repository of ready-made, tested automations for Sheets, Apps Script, Python and Node. Each one comes with setup notes and sample data.

  • A new recipe added every week
  • Everything in the free library, plus the longer, more complete versions
  • Use them in your own business as much as you like
  • Cancel anytime by email (or the Stripe customer portal once available)

Recipe Club opens soon: join the waitlist

You'll need a free GitHub account. After checkout, reply with your GitHub username and we'll send the repo invite, usually within one business day.

Or buy a one-time recipe pack ($19–$29)

  • Clean Data Pack $19
    Dedupe, cleaning, merging and normalizing scripts and formulas for messy exports, with a sample sheet.
    Coming soon
  • Forms & Inbox Pack $19
    Form auto-replies, request routing, a lead log and follow-up nudges, ready to paste into Apps Script.
    Coming soon
  • Money Ops Pack $29
    Invoice reminders, a monthly revenue dashboard with QUERY, a receipts log and nightly backups.
    Coming soon

Written with AI assistance. Script logic is unit-tested; formulas follow documented Google Sheets behavior. Always try new automations on a copy of your sheet first.