Skip to content
// Sheets · Apps Script

onEdit vs onChange triggers in Google Sheets.

onEdit fires when a user types in a cell and gives you a range; onChange fires on structural events (row inserts, pastes, IMPORTRANGE refreshes) but carries no range, only a changeType string. Knowing which to install saves a lot of head-scratching when your trigger silently does nothing.

I'm trying to figure out why my Apps Script trigger isn't firing when rows get inserted, or why I can't get a cell reference inside an onChange handler.

The script

copy · paste · trigger
triggers.gs
Apps Script
// onEdit: fires on user cell edits; event has range + value
function onEdit(e) {
  var sheet = e.range.getSheet();
  var col = e.range.getColumn();
  if (sheet.getName() !== 'Orders' || col !== 3) return;
  var newVal = e.value;
  var oldVal = e.oldValue;
  Logger.log('Changed from ' + oldVal + ' to ' + newVal);
}

// onChange: fires on structural changes; NO e.range available
function onChange(e) {
  var type = e.changeType; // INSERT_ROW, REMOVE_ROW, INSERT_COLUMN, PASTE, etc.
  if (type === 'INSERT_ROW' || type === 'REMOVE_ROW') {
    SpreadsheetApp.getActiveSpreadsheet()
      .getSheetByName('Log')
      .appendRow([new Date(), 'Row structure changed: ' + type]);
  }
}

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

Walkthrough

What each trigger actually sees

onEdit fires the moment a user finishes editing a cell — pressing Enter, Tab, or clicking away. The event object (e) gives you e.range, e.value, e.oldValue, e.user, and the sheet name. That range is a single-cell Range object, so you can call getRow(), getColumn(), and getSheet() on it without any extra lookups.

onChange fires on a broader class of events: row or column insertions and deletions, paste operations, IMPORTRANGE and other formula refreshes that change the cell graph, and format changes. The critical difference is that e.range does not exist on an onChange event. Trying to read e.range.getRow() inside an onChange handler throws a TypeError at runtime, which is silent unless you check the execution log.

The changeType string tells you what happened: INSERT_ROW, REMOVE_ROW, INSERT_COLUMN, REMOVE_COLUMN, PASTE, FORMAT, or OTHER. That is all the location information you get. If you need to know which row was inserted, you have to infer it from a snapshot you took before, or use a different architecture (like recording row counts on each onEdit and diffing on onChange).

One thing that trips people up: a simple trigger named onEdit installs automatically when you save the script. onChange does not — you must go to Triggers (the clock icon in the editor) and add an installable trigger pointing at your onChange function, tied to the spreadsheet's On change event. Without that step, the function exists but never runs.

Installing the onChange trigger correctly

Simple triggers (onEdit, onOpen, onSelectionChange) run under the user's session automatically. They are sandboxed: they cannot call services that require authorization, like sending email or writing to a different spreadsheet.

Installable triggers run under the account that created them and can call any authorized service. onChange must be installable. To set it up: open the script editor, click the trigger icon (or Extensions > Apps Script > Triggers), click Add Trigger, choose your onChange function, set event source to From spreadsheet, and set event type to On change.

The first time you save an installable trigger, Google asks for authorization. The trigger then runs as your account, which means it can appendRow to a log sheet, send a notification via MailApp, or call an external API. The tradeoff: if you share the spreadsheet with a colleague who also has the script, their edits fire your trigger running as you, not them.

I keep a small guard at the top of every onChange handler — check e.changeType against an allowlist before doing any work. The OTHER and FORMAT types fire surprisingly often (formatting a cell range counts), and without that guard you end up with a log sheet that fills with noise before anything interesting happens.

When onEdit silently misses your event

Three scenarios where onEdit will not fire even though the sheet changed: first, a formula updating its output (IMPORTRANGE pulling fresh data, QUERY recalculating) does not count as a user edit, so onEdit never sees it. onChange with changeType OTHER or PASTE usually does.

Second, a script itself writing to the sheet via setValue or setValues does not trigger onEdit. Apps Script edits are excluded to prevent infinite loops. If you need to react to programmatic writes, you have to call your logic directly from the writing script rather than relying on the trigger.

Third, pasting a large range via Ctrl+V fires onEdit for exactly one cell in simple trigger mode. Paste is treated as a structural event, so onChange with changeType PASTE is the correct hook if you want to react to any paste regardless of size.

The practical decision tree: if you care about what value changed and where, use onEdit. If you care that the structure shifted or a bulk operation happened and you do not need the exact cell, use onChange. Many real scripts need both, wired to separate functions with clear responsibility boundaries.

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
Why does e.range throw an error inside my onChange function?
onChange events do not include a range. The event object only has changeType, source, authMode, and triggerUid. Reading e.range returns undefined, so calling any method on it throws a TypeError. Use e.changeType to branch your logic instead.
Does onEdit fire when another script edits the sheet?
No. Apps Script writes via setValue, setValues, or any Range method do not trigger onEdit. The exclusion is intentional to prevent trigger loops. You need to call your handler function directly from the writing script if you want to react to its changes.
Can I use a simple (auto-installed) trigger for onChange?
No. onChange must be an installable trigger, set up manually in the Triggers panel or via ScriptApp.newTrigger(). Simple triggers only cover onEdit, onOpen, onFormSubmit, and onSelectionChange. An installable trigger runs under your account and can use authorized services; a simple trigger cannot.
IMPORTRANGE refreshed and I want to react to the new data — which trigger?
onChange with changeType OTHER. IMPORTRANGE refreshes are not user edits and do not surface as a specific changeType; they fall under OTHER. You will not get a cell location, so your handler needs to read the relevant range explicitly (e.g., sheet.getRange('A:A').getValues()) rather than relying on event data.
// one good script a week

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