Detect duplicate rows on a composite key
Flag rows in the Invoices sheet that match across (client, invoiceNumber).
Published Nov 5, 2025
Northwind’s Invoices sheet is filled from a few directions at once — some rows
come from an export, some are typed in by hand, some are copied from last
month’s tab as a starting point. Sooner or later the same invoice ends up on the
sheet twice. An invoice number on its own is not enough to spot that, because
two different clients can both have an invoice number 001.
This automation flags genuine duplicates by matching on a composite key — the
pair (client, invoiceNumber) together. Any two rows that share both values
are coloured pink, so a duplicate stands out at a glance without anyone scanning
the sheet line by line.
What you’ll need
- An
Invoicessheet with a header row. Two columns must be headed exactlyclientandinvoiceNumber— together they form the key. - The spreadsheet ID, dropped into the config block in place of the placeholder.
- A daily trigger, set up once — see Trigger it.
The script
// The spreadsheet that holds the Invoices sheet.
const INVOICES_SHEET_ID = '1abcInvoicesSheetId';
// The two header names whose values, together, form the duplicate key.
const KEY_HEADERS = ['client', 'invoiceNumber'];
// The pink fill used to mark a duplicate row.
const FLAG_COLOUR = '#fde2e1';
/**
* Scans the Invoices sheet and colours every row that shares its
* (client, invoiceNumber) pair with another row.
*/
function flagComposites() {
const sheet = SpreadsheetApp.openById(INVOICES_SHEET_ID).getSheets()[0];
// 1. Read the whole sheet and split off the header row.
const values = sheet.getDataRange().getValues();
const [header, ...rows] = values;
if (!rows.length) {
Logger.log('No invoice rows — nothing to check.');
return;
}
// 2. Map each header name to its column index for easy lookup.
const col = Object.fromEntries(header.map((h, i) => [h, i]));
// 3. Group sheet row numbers by their composite key.
const seen = new Map();
rows.forEach((r, i) => {
const key = KEY_HEADERS.map((h) => r[col[h]]).join('|');
if (!seen.has(key)) seen.set(key, []);
seen.get(key).push(i + 2); // +2: skip the header, switch to 1-based rows
});
// 4. Clear any colouring from a previous run.
sheet.getRange(2, 1, rows.length, header.length).setBackground(null);
// 5. Colour every key that appears on more than one row.
let flagged = 0;
for (const ids of seen.values()) {
if (ids.length < 2) continue;
for (const r of ids) {
sheet.getRange(r, 1, 1, header.length).setBackground(FLAG_COLOUR);
flagged++;
}
}
Logger.log(`Flagged ${flagged} duplicate rows.`);
}
How it works
flagCompositesopens the invoices spreadsheet and reads every row in one call, then splits the header off the data.- If there are no data rows it logs a message and stops — no point checking an empty sheet.
- It builds a small lookup,
col, that maps each header name to its column index, so the script never depends on columns sitting in a fixed order. - It walks the rows, builds a key for each one by joining the
clientandinvoiceNumbervalues with a|, and records the sheet row number under that key in aMap. - It clears any pink fill left by an earlier run, so a row that is no longer a duplicate goes back to plain.
- It looks at each group of row numbers. Any key with two or more rows is a duplicate, and every row in that group gets the pink fill.
Example run
Suppose the Invoices sheet contains these rows:
| client | invoiceNumber | amount | |
|---|---|---|---|
| Acme Ltd | 001 | 1,200 | |
| Beta Co | 001 | 480 | |
| Acme Ltd | 002 | 950 | |
| Acme Ltd | 001 | 1,200 | ← pink |
| Acme Ltd | 001 | 1,200 | ← pink |
Rows 2 and 3 share invoice number 001 but belong to different clients, so
they are left alone. Rows 2, 5 and 6 all carry the pair (Acme Ltd, 001) — the
script colours rows 5 and 6, and row 2 as well, since all three are part of the
same duplicate group.
Trigger it
Run the check once a day so duplicates are caught the morning after they are entered:
- In the Apps Script editor, open Triggers (the clock icon).
- Click Add trigger.
- Choose the
flagCompositesfunction, event source Time-driven, type Day timer, and pick an early-morning hour. - Save and approve the authorisation prompt.
You can also run flagComposites by hand from the editor whenever you have
just pasted in a batch of rows.
Watch out for
- The match is exact.
Acme LtdandAcme Ltd(with a trailing space) count as different clients and will not be flagged as duplicates. Trim your data, or add.toString().trim()when building the key. - Case matters too —
acme ltdandAcme Ltdare treated as separate clients. Lower-case both values in the key if your data is inconsistent. - It flags every row in a duplicate group, including the original. The pink fill tells you a row has a twin; deciding which copy to keep is still a human call.
- The script only colours rows — it never deletes anything. That is deliberate: it is safer to review duplicates than to have a script remove them.
- A blank
clientorinvoiceNumberproduces a key like|001. Several empty rows will all match each other and get flagged — filter out blank rows first if that is noisy.
Related
Build an OKR tracker with progress rollups
Aggregate Northwind's key-result progress into objective-level scores automatically.
Updated Jan 14, 2026
Build a recurring-task generator
Spawn new Northwind task rows on a daily or weekly cadence from a Recurring sheet.
Updated Jan 10, 2026
Build a multi-sheet search-and-jump tool
Find a value across every tab of a workbook and click through to the cell that contains it.
Updated Jan 7, 2026
Auto-rebuild grouped summaries on edit
Refresh pivot-style rollups the instant data changes — no manual recompute.
Updated Jan 4, 2026
Build a data-quality scorecard
Grade any sheet on completeness, validity, and freshness — surface gaps as a single score.
Updated Dec 31, 2025