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
onEditcannot 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
onEditto 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
changeTypestrings 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;
changeTypelogged once in staging -
EDITignored 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!


