Auto-number invoices and POs without gaps
Assign sequential, gap-free document numbers as new rows appear in the Invoices sheet.
Published Nov 19, 2025
Northwind raises invoices and purchase orders from a shared spreadsheet, and
tax authorities care about one thing in particular: the numbering must be
sequential with no gaps. A missing INV-1043 invites questions about a
deleted or hidden invoice. The risk is not malice — it is two people adding
rows at the same time, or a formula that grabs the wrong “last” number and
skips one.
This script assigns the next number to every row that does not have one yet,
drawing from a counter kept in Script Properties so the sequence survives
across runs. It wraps the work in a LockService lock, so two concurrent runs
can never hand out the same number or leave a gap between them.
What you’ll need
- A Google Sheet whose first tab holds invoice or PO rows, with a header row.
- A column headed exactly
invoiceNumberwhere the numbers will be written. - The spreadsheet ID, taken from its URL.
- Nothing else — the counter is created automatically the first time the script runs.
The script
// The spreadsheet that holds your invoice / PO rows.
const INVOICES_SHEET_ID = '1abcInvoicesSheetId';
// Header of the column the numbers are written into.
const NUMBER_COLUMN = 'invoiceNumber';
// Prefix and starting value for the sequence.
const NUMBER_PREFIX = 'INV-';
const FIRST_NUMBER = 1001;
// Script Property that stores the next number to hand out.
const COUNTER_KEY = 'NEXT_INVOICE';
// How long to wait for the lock before giving up, in milliseconds.
const LOCK_TIMEOUT_MS = 30_000;
/**
* Assigns the next sequential, gap-free document number to every row
* that does not already have one. Guarded by a script lock so two runs
* can never share or skip a number.
*/
function assignInvoiceNumbers() {
// 1. Take the script lock — concurrent runs queue here.
const lock = LockService.getScriptLock();
lock.waitLock(LOCK_TIMEOUT_MS);
try {
const sheet = SpreadsheetApp.openById(INVOICES_SHEET_ID).getSheets()[0];
const values = sheet.getDataRange().getValues();
// 2. Split off the header and map column names to indexes.
const [header, ...rows] = values;
const col = Object.fromEntries(header.map((h, i) => [h, i]));
if (rows.length === 0) {
Logger.log('No data rows — nothing to number.');
return;
}
// 3. Read the next number to hand out from Script Properties.
const props = PropertiesService.getScriptProperties();
let next = parseInt(props.getProperty(COUNTER_KEY) || FIRST_NUMBER, 10);
// 4. Fill in any row whose number cell is still blank.
let assigned = 0;
rows.forEach((row, i) => {
if (row[col[NUMBER_COLUMN]]) return; // already numbered — skip
// values[i + 1] because row 0 of `values` is the header.
values[i + 1][col[NUMBER_COLUMN]] = NUMBER_PREFIX + next;
next++;
assigned++;
});
// 5. Write the whole grid back, then save the updated counter.
sheet.getDataRange().setValues(values);
props.setProperty(COUNTER_KEY, String(next));
Logger.log('Assigned ' + assigned + ' new numbers.');
} finally {
// 6. Always release the lock, even if something above threw.
lock.releaseLock();
}
}
How it works
assignInvoiceNumbersacquires the script lock and waits up to 30 seconds. If another copy of the script is already running, this call queues behind it instead of running in parallel.- It reads the whole sheet, peels off the header row, and builds a
collookup so the code refers toinvoiceNumberby name rather than a fixed index. - If there are no data rows it logs and returns early — the
finallyblock still releases the lock. - It reads the next number from Script Properties, falling back to
FIRST_NUMBER(1001) the very first time. - It walks the rows, and for any row whose number cell is empty it writes
INV-<next>and advances the counter. Rows already numbered are left untouched, so numbers are stable once assigned. - The full grid is written back in one
setValuescall, the updated counter is saved, and the lock is released in thefinallyblock so it is freed even if an error is thrown.
Example run
The sheet has three new rows with blank invoiceNumber cells, and the counter
sits at 1043:
Before (invoiceNumber) | After |
|---|---|
INV-1041 | INV-1041 |
INV-1042 | INV-1042 |
| (blank) | INV-1043 |
| (blank) | INV-1044 |
| (blank) | INV-1045 |
The Script Property NEXT_INVOICE now reads 1046, ready for the next run.
Trigger it
Run this automatically so new rows are numbered without anyone remembering to:
- In the Apps Script editor, open Triggers (the clock icon).
- Click Add Trigger.
- Choose
assignInvoiceNumbers, then either a Time-driven minutes timer every 10 minutes, or From spreadsheet → On change for near-instant numbering.
The On change trigger fires on edits, so combine it with the lock — which the
script already has — to stay gap-free under concurrent edits.
Watch out for
- The counter lives in Script Properties, not the sheet. If you delete a numbered row, the counter does not roll back — the next number is still handed out, leaving a visible gap. Either avoid deleting numbered rows or void them in place.
- If you ever need to reset or migrate the sequence, change
NEXT_INVOICEin Project Settings → Script Properties directly; do not edit numbers in the sheet and expect the counter to follow. - The lock serialises runs, so two
On changetriggers a second apart will queue rather than collide — but a run that holds the lock for the full 30-second timeout will make the next one fail to acquire it. Keep the sheet small enough to process quickly. - The column name must match
NUMBER_COLUMNexactly, including case. A header typo meanscol[NUMBER_COLUMN]isundefinedand numbers are written into the wrong place.
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