Build an audit log of every edit
Record who changed which cell and when on a hidden Audit tab — accountability without ceremony.
Published Oct 8, 2025
Northwind’s Pricing sheet is shared with half the studio, and every so often a
number changes that nobody admits to. Without a record, the conversation goes in
circles — was it a typo, a deliberate update, a stale paste? — and the only fix
is to lock the sheet down so hard that nobody can do their job.
An audit log gives you the middle ground. This trigger fires on every edit and
writes one row to a hidden Audit tab: the timestamp, the editor, the sheet,
the exact cell, the old value, and the new value. Nobody has to remember to log
anything, and when a number looks wrong you can see who touched it and what it
used to be.
What you’ll need
- The spreadsheet you want to monitor. Edit rights on it are enough — the script runs inside the spreadsheet itself.
- Nothing else. The script creates and hides the
Audittab on the first edit.
The script
// Name of the tab that stores the log. It is created and hidden
// automatically the first time an edit is recorded.
const AUDIT_TAB = 'Audit';
// The header row written to a freshly created Audit tab.
const AUDIT_HEADER = ['when', 'user', 'sheet', 'cell', 'old', 'new'];
/**
* Records every edit to the spreadsheet on a hidden Audit tab. Wire this
* up as an installable On edit trigger so it can read the editor's email.
*
* @param {Object} e The edit event passed in by the trigger.
*/
function onEdit(e) {
// A manual run has no event object — there is nothing to log.
if (!e || !e.range) return;
const ss = SpreadsheetApp.getActive();
const audit = getAuditSheet(ss);
// Append one row describing this single edit.
audit.appendRow([
new Date(), // when the edit happened
Session.getActiveUser().getEmail(), // who made it
e.range.getSheet().getName(), // which tab
e.range.getA1Notation(), // which cell
e.oldValue || '', // value before the edit
e.value || '', // value after the edit
]);
}
/**
* Returns the Audit tab, creating and hiding it (with a header row) the
* first time it is needed.
*
* @param {Spreadsheet} ss The active spreadsheet.
* @return {Sheet} The Audit tab, ready to append to.
*/
function getAuditSheet(ss) {
const existing = ss.getSheetByName(AUDIT_TAB);
if (existing) return existing;
// First run: create the tab, hide it, and lay down the header.
const sheet = ss.insertSheet(AUDIT_TAB);
sheet.hideSheet();
sheet.appendRow(AUDIT_HEADER);
return sheet;
}
How it works
onEditis the entry point. It first checks for a real event object — a manual run from the editor has noe, so the function exits rather than logging a phantom edit.getAuditSheetlooks for theAudittab. If it does not exist, it creates it, hides it from the tab bar so it stays out of the way, and writes the header row once.- Back in
onEdit, a single row is appended capturing the moment: the timestamp, the editor’s email fromSession.getActiveUser(), the tab name, the cell in A1 notation, and the values before and after. e.oldValueande.valueare coalesced to empty strings, so clearing a cell or filling a previously blank one still logs a tidy row instead ofundefined.
Example run
Suppose someone changes cell C4 on the Pricing tab from 49 to 59. A new
row lands on the hidden Audit tab:
| when | user | sheet | cell | old | new |
|---|---|---|---|---|---|
| 2025-10-08 14:22:07 | [email protected] | Pricing | C4 | 49 | 59 |
Clear that cell afterwards and a second row appears, with 59 in the old
column and a blank new. The tab grows one row per edit, oldest at the top, so
it reads as a plain chronological history.
Trigger it
A simple onEdit function only runs with the editor’s limited permissions and
cannot reliably read who made the change. You need an installable trigger:
- In the Apps Script editor open Triggers (the clock icon).
- Add Trigger → choose
onEdit, event source From spreadsheet, event type On edit. - Approve the authorisation prompt. From now on every edit is logged.
To review the log, right-click any tab, choose Show sheet, and pick Audit.
Watch out for
Session.getActiveUser().getEmail()returns an empty string for users outside your Workspace domain. For external editors you will still see the change and its values, but theusercolumn will be blank.- Only an installable trigger can read the editor’s email. A plain
onEditsaved in the project will fire, but theusercolumn will come back empty every time. - The log is append-only and never pruned. On a busy sheet the
Audittab grows steadily — archive or trim it every few months so the spreadsheet stays fast. - Hiding the tab is not security. Any editor can unhide it and read or delete the log. For a tamper-proof record, write the rows to a separate spreadsheet that editors of the monitored sheet cannot open.
- Large bulk edits — a paste over many cells, or a fill-down — fire one event for
the whole range, so
cellmay be a range likeB2:B40andold/newmay be blank. The log captures the action, not every individual cell within it.
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