Skip to content
// field note2026 · Jul5 min read

Why most Apps Script tutorials are wrong about triggers.

If your onEdit “doesn’t work”, it’s almost never a bug in your code — it’s the wrong kind of trigger.

There are three kinds of trigger, not one

Apps Script has three trigger mechanisms and they are not interchangeable. A simple trigger is a function with a reserved name — onOpen, onEdit, onSelectionChange — that Google runs automatically. An installable trigger is one you attach by hand (the clock icon in the editor, or ScriptApp in code) to any function you like. A time-driven trigger is an installable trigger fired on a schedule instead of by an event.

Most tutorials show you an onEdit function, tell you it runs when the sheet changes, and stop there. That works right up until your onEdit needs to send an email or read another file — and then it silently does nothing, with no error, and you lose an evening.

Simple triggers can’t do the interesting things

A simple trigger runs in a restricted context: it cannot call any service that requires authorization. No GmailApp.sendEmail, no opening a different spreadsheet by ID, no UrlFetchApp, no access to Drive files it wasn’t handed. It also has to finish in 30 seconds and won’t run for a user who only has view access.

This is by design — a simple trigger runs without asking anyone for permission, so Google won’t let it touch anything sensitive. The moment your automation needs to reach outside the current document, you’ve outgrown the simple trigger and you don’t get told.

Installable triggers run as you, with your permissions

The fix is an installable trigger. You attach it to a normal function (call it whatever you want) and it runs with your authorization — so it can send mail, hit other files, and call external APIs. You add one in the editor under the clock icon → Add Trigger, or in code so your project is self-installing.

Attaching it in code means someone can install the whole automation by running one setup function instead of clicking through the UI. Guard it so re-running setup doesn’t stack duplicate triggers:

function installTriggers() {
  // Remove any of our existing triggers first — re-running setup
  // otherwise stacks duplicates that all fire on the same event.
  ScriptApp.getProjectTriggers()
    .filter((t) => t.getHandlerFunction() === 'onEditInstallable')
    .forEach((t) => ScriptApp.deleteTrigger(t));

  const ss = SpreadsheetApp.getActive();
  ScriptApp.newTrigger('onEditInstallable')
    .forSpreadsheet(ss)
    .onEdit()
    .create();
}

function onEditInstallable(e) {
  // Runs with YOUR auth — sending mail, opening other files, UrlFetch all work.
  GmailApp.sendEmail('[email protected]', 'Sheet edited', e.range.getA1Notation());
}

onEdit only fires on human edits

The gotcha that catches everyone: an onEdit trigger — simple or installable — fires only when a person edits a cell. It does not fire when a script writes with setValue(), when a formula recalculates, or when a form submission drops a row in. If your trigger needs to react to a form, use onFormSubmit; if it needs to react to another script’s output, have that script call your function directly.

Time-driven triggers are not cron

A time-driven trigger set to “every day at 6am” actually means “sometime in the 6–7am hour.” The schedule is a window, not a minute. Never build logic that assumes a precise fire time, and never assume it fires exactly once — transient failures and retries happen. Anything a time-driven trigger does should be idempotent: safe to run twice with the same result. “Archive rows marked done” is idempotent; “increment a counter” is not.

FAQ

Why does my onEdit function do nothing when it sends an email?
A simple onEdit trigger (a function literally named onEdit) runs without authorization and cannot call services like GmailApp. Convert it to an installable trigger attached to a differently-named function — that runs with your permissions and can send mail.
Does onEdit fire when a script changes a cell?
No. onEdit fires only on edits made by a person in the UI. Programmatic setValue() calls, formula recalculations, and form submissions do not trigger it. Use onFormSubmit for forms, or call your handler directly from the script that made the change.
Can I rely on a time-driven trigger firing at an exact time?
No. Time-driven triggers fire within an approximate window (e.g. sometime in the chosen hour) and can occasionally run more than once. Make the work idempotent so a double-run is harmless, and don’t depend on a precise minute.

Skip the boilerplate.

Describe the automation in a sentence and Gnaw writes the Apps Script — triggers, pagination, edge cases handled. Free, no login.