Skip to content
// Sheets · Apps Script

Record who edited a row in Google Sheets.

Use an installable onEdit trigger and Session.getActiveUser() to automatically log the editor's email, timestamp, and changed cell whenever a row is updated in Google Sheets.

I need to know which team member changed a row in my shared spreadsheet, but the built-in revision history doesn't show it per-cell.

The script

copy · paste · trigger
onEditInstallable.gs
Apps Script
// Installable trigger — runs as the script owner, sees editor email
// Append a log row: timestamp | editor email | sheet | cell | old | new
function recordEdit(e) {
  var sheet = e.source.getSheetByName('EditLog');
  if (!sheet) {
    sheet = e.source.insertSheet('EditLog');
    sheet.appendRow(['Timestamp', 'Editor', 'Sheet', 'Cell', 'Old Value', 'New Value']);
  }
  var editor = Session.getActiveUser().getEmail();
  var cell = e.range.getA1Notation();
  var sheetName = e.range.getSheet().getName();
  var oldVal = e.oldValue !== undefined ? e.oldValue : '';
  var newVal = e.value !== undefined ? e.value : '';
  sheet.appendRow([new Date(), editor, sheetName, cell, oldVal, newVal]);
}

Need a variant? Gnaw writes a custom version from one sentence — fields, triggers, edge cases handled.

Walkthrough

Why the simple trigger doesn't work

The first time I tried this, I dropped a plain function named onEdit into the script editor, called Session.getActiveUser().getEmail(), and got back an empty string every single time. That's not a bug — it's documented behavior. Simple triggers run in a restricted security context that intentionally withholds the caller's identity. Google's reasoning is that simple triggers fire automatically without requesting any OAuth scope, so they can't expose personal data like an email address.

To get the editor's email, the trigger must be an installable on-edit trigger. Installable triggers run under the authorization of the script owner (who granted the scopes), and they receive the editing user's identity through the same Session API. The catch: that identity resolves only when the editor and the script owner share a Google Workspace domain. Personal gmail.com accounts editing a sheet owned by a gmail.com script owner will return a blank email — Google deliberately blocks cross-account identity surfacing outside Workspace.

Install the trigger manually

Open the Apps Script editor from Extensions > Apps Script. Paste the recordEdit function from the snippet above, then save. Do not name the function onEdit — that naming convention is reserved for simple triggers and will cause the runtime to call it in restricted mode even if you also have an installable trigger pointing at it. Use any other name.

To wire it up: in the Apps Script editor, open the Triggers panel (the alarm-clock icon on the left sidebar), click Add Trigger, set the function to recordEdit, the event source to From spreadsheet, and the event type to On edit. Save. Google will ask you to authorize the script — approve it with the account you want to be the script owner. From that point on, every edit by any collaborator appends a row to the EditLog sheet, which the script creates automatically on the first edit if it doesn't already exist.

One operational note: the e.oldValue property is only populated when a single cell is edited and it previously had a value. Editing a range of cells or clearing a cell that was already empty leaves it undefined. The script guards against that with the ternary fallbacks.

What the log row actually contains

Each appended row has six columns: the timestamp as a JavaScript Date object (Sheets formats it as a datetime), the editor's email, the sheet name, the cell address in A1 notation like B7, the old value, and the new value. The EditLog sheet accumulates rows indefinitely — worth adding a manual purge step or a time-based trigger that deletes rows older than 90 days once the sheet grows large.

If you need to log edits to specific sheets only, add a guard at the top of recordEdit: check e.range.getSheet().getName() against an allowlist and return early otherwise. The event object e is the same one a simple trigger receives, so e.source, e.range, e.value, and e.oldValue all behave identically — only Session.getActiveUser() is different.

Want a custom version?

Describe your sheet and the rule you want. Gnaw writes the Apps Script — fields, triggers, edge cases — in one shot.

FAQ

4 questions
Session.getActiveUser().getEmail() returns an empty string — what's wrong?
You're almost certainly running a simple trigger (a function named onEdit, or any trigger firing in restricted mode). Switch to an installable trigger: Triggers panel > Add Trigger > On edit, pointing at your function. Simple triggers explicitly block identity APIs.
The editor email shows up blank even with an installable trigger
This happens when the editor's account is a personal gmail.com account and the script owner is also a gmail.com account. Google only surfaces email identity within a shared Google Workspace domain. Outside Workspace, getEmail() returns an empty string by design. There's no workaround on the Apps Script side.
Can I log who edited without using a trigger at all?
Not in real time. The spreadsheet's built-in revision history (File > Version history) shows per-cell edits with editors, but it's browser-only and not queryable programmatically. If you need a machine-readable audit trail, an installable trigger writing to a log sheet is the only supported path.
Will the trigger still fire if I share the sheet with someone outside my organization?
Yes, the trigger fires and the log row is written. But for editors outside your Workspace domain, the email column will be blank. The timestamp, sheet name, cell, old value, and new value still populate correctly — you just lose attribution for external editors.
// one good script a week

Get a working Apps Script snippet in your inbox, weekly.