onChange vs onEdit: catch inserts, deletes, and tab moves

TL;DR — When to use installable onChange vs onEdit in Apps Script — e.changeType for inserts, deletes, and tab moves, how to ignore noise, and how this pairs with (not duplicates) the debounce-onEdit guide.

onEdit is the hammer everyone reaches for. Then a teammate inserts ten rows, deletes a tab, or drags a sheet to reorder — and your sync never runs, because none of that is an “edit” of a cell value. Installable onChange sees structural events. Use the right trigger; don’t pretend onEdit is a file-system watcher.

This guide is about which event to subscribe to and how to branch on e.changeType. For “onEdit fired twenty times while someone held down Enter,” use the queued debounce-onEdit guide — complementary, not a rewrite.

TL;DR

  • onEdit (simple or installable): cell value / formula edits; has range, value, oldValue, auth user (installable)
  • onChange (installable only): structural changes — inserts, deletes, sheet renames/moves, etc. via e.changeType
  • Ignore noisy change types you don’t care about
  • Pair with debounce when the problem is edit thrash, not missing insert/delete events
  • Simple onEdit cannot see another user’s edits the same way — know the limits

Using the Apps Script editor a lot? NitroGAS drops free themes & snippets right into script.google.com — optional Co-Pilot when you want a boost.

When to use which

Need Prefer
Format a cell after someone types onEdit
Stamp lastEditedAt on the row onEdit
Rebuild a mirror after rows are inserted/deleted onChange (INSERT_ROW, REMOVE_ROW)
React to a tab being added, deleted, or renamed onChange (INSERT_GRID, REMOVE_GRID, OTHER / sheet events — verify in your runtime)
Catch column insert/delete onChange (INSERT_COLUMN, REMOVE_COLUMN)
Someone pasted a rectangle of values Usually onEdit (installable); still not a row-insert

Rule of thumb: value change → onEdit. Shape / structure change → onChange.

What not to do

  • Don’t expect simple onEdit to fire for every collaborator scenario you care about — installable is often required
  • Don’t put heavy UrlFetch work directly in either trigger without debounce / queue / lock
  • Don’t duplicate a full debounce tutorial here — link the pattern, keep this guide about event selection
  • Don’t assume changeType strings stay undocumented folklore — log them in a dry-run sheet once per project

onChange: branch on e.changeType

Installable only: Triggers → Add trigger → event source From spreadsheet → event type On change.

/** * Installable onChange handler. * @param {Object} e */ function onSpreadsheetChange(e) { var type = (e && e.changeType) || ''; // Ignore pure edits if you also have onEdit — avoid double work if (type === 'EDIT') return; var interesting = { INSERT_ROW: true, REMOVE_ROW: true, INSERT_COLUMN: true, REMOVE_COLUMN: true, INSERT_GRID: true, // sheet added REMOVE_GRID: true, // sheet deleted OTHER: true // includes many sheet renames / moves — filter carefully }; if (!interesting[type]) { console.log('onChange ignored', type); return; } console.log('onChange handling', type); rebuildDependentViews_(); }

Ignore noise: EDIT often shows up on the change stream too. If you already handle cell edits in onEdit, skip EDIT here so you don’t rebuild twice. OTHER is a grab bag — log first, then narrow.

Minimal onEdit companion (not a debounce tutorial)

function onEditInstallable(e) { if (!e || !e.range) return; var sheet = e.range.getSheet(); if (sheet.getName() !== 'Intake') return; // Light touch — or call your debounced scheduler from the debounce-onEdit guide stampRowMetadata_(e.range.getRow()); }

When typists thrash a cell, wrap the expensive part with the debounce / skip pattern from the queue — same installable onEdit, calmer execution. This page stays focused on catching inserts/deletes/tab moves that onEdit never sees.

Practical setup

1. Bound project → Triggers → On change → onSpreadsheetChange 2. Log e.changeType to a scratch sheet for a day of real edits 3. Whitelist the types you care about; drop EDIT if onEdit exists 4. Keep rebuilds under a document lock 5. Add installable onEdit only for cell-level stamps / validation function logChangeType_(e) { var ss = SpreadsheetApp.getActive(); var log = ss.getSheetByName('ChangeLog') || ss.insertSheet('ChangeLog'); log.appendRow([new Date(), (e && e.changeType) || '', Session.getActiveUser().getEmail()]); }

Ship the logger for a week in staging; your mental model of OTHER will get less mystical.

Failure modes worth naming

  • Simple onEdit only — misses structure; also limited auth/user visibility
  • onChange without filter — every edit rebuilds the world (EDIT + structure)
  • Heavy work inline — both triggers time out; queue to a time-driven drain
  • Wrong spreadsheet — standalone project’s change trigger must be attached to the right file
  • Assuming delete-row gives you the deleted values — it’s a notification, not an undo buffer; archive-before-delete if you need data

Soft Co-Pilot note

NitroGAS can help you wire trigger stubs and find lock/debounce helpers; Co-Pilot can draft an interesting[changeType] whitelist from “rebuild when rows or tabs change.” You still install the trigger in the UI and verify with a real insert/delete. Free core; Co-Pilot optional.

Closing checklist

  • Know whether you need cell edits, structure, or both
  • Installable onChange added; changeType logged once in staging
  • EDIT ignored onChange when onEdit already handles cells
  • Debounce pattern used for thrashy onEdit paths (see debounce-onEdit guide)
  • Rebuilds locked; no double-fire surprise after row insert

Happy Coding!