Apps Script Triggers
Simple vs installable
|
Simple (onOpen, onEdit, onInstall, onSelectionChange) |
Installable (ScriptApp.newTrigger(...)) |
| Registration |
Automatic — GAS calls the global function by name |
Explicit — created programmatically or via the Triggers UI |
| Runs as |
The user currently viewing the file, with their authorization |
The user who created the trigger, with that user's authorization, regardless of who triggers it |
| Can call authorized services |
No — no UrlFetchApp, no most advanced services, no services needing OAuth consent |
Yes — full access to whatever the installing user has authorized |
| Typical use |
UI-only reactions (build a menu, format a cell) |
Anything that calls an external API, sends email, or must run reliably regardless of viewer permissions |
If a simple onEdit needs to call UrlFetchApp or MailApp, it will silently fail or throw a permission error — convert it to an installable trigger instead. This is the most common "trigger works when I test it, breaks for other users" bug: the simple trigger works for you (the owner, already authorized) and fails for a viewer/editor without those grants.
Event object shapes
| Trigger |
Event object fields |
onEdit |
{ range, oldValue, value, source, user, authMode } — oldValue/value are only present for single-cell edits with a scalar value; multi-cell pastes omit them (check range.getNumRows()/getNumColumns() instead of assuming value exists) |
onChange |
{ changeType, source, user } — changeType is one of EDIT, INSERT_ROW, INSERT_COLUMN, REMOVE_ROW, REMOVE_COLUMN, INSERT_GRID, REMOVE_GRID, FORMAT, OTHER |
onFormSubmit |
{ values, namedValues, response, source, triggerUid } — namedValues maps question titles to arrays (a checkbox question yields multiple values) |
| Time-driven |
No event object — the handler runs with no arguments |
Quotas
Execution time limit: 6 minutes per invocation, for both consumer and Google Workspace accounts as of this writing. Trigger-specific caps (total triggers per user/script, total trigger runtime per day) differ by account type and change without notice — don't trust a number pasted into a skill, including this one. Query the live docs instead of the number above:
../apps-script-services/scripts/check-quotas.sh "Trigger"
../apps-script-services/scripts/check-quotas.sh "runtime"
(The script lives in the apps-script-services skill, which owns the broader quota reference — see that skill if it isn't installed alongside this one.)
Registering installable triggers without duplicating them
ScriptApp.newTrigger(...).create() is not idempotent — running the setup code twice creates two triggers calling the same handler, which then runs twice per event. Always check existing triggers by handler function name before creating:
function ensureEditTrigger(spreadsheetId: string, handlerFunctionName: string): void {
const alreadyExists = ScriptApp.getProjectTriggers().some(
(trigger) => trigger.getHandlerFunction() === handlerFunctionName,
);
if (alreadyExists) return;
ScriptApp.newTrigger(handlerFunctionName)
.forSpreadsheet(spreadsheetId)
.onEdit()
.create();
}
Run this kind of setup once, from an explicit "install" action (a menu item, onInstall, or a setup script) — not from inside the trigger handler itself.
Verification
1---2name: apps-script-triggers3description: Explains Google Apps Script's simple vs installable triggers, their event object shapes, authorization differences, and how to register/manage installable triggers programmatically without duplicating them. Use when adding onOpen/onEdit/onChange/onFormSubmit/time-driven behavior, or when a trigger isn't firing, is duplicated, or fails with an authorization error. Framework-agnostic — applies with or without bootgs.4license: Apache-2.05---67# Apps Script Triggers89## Simple vs installable1011| | Simple (`onOpen`, `onEdit`, `onInstall`, `onSelectionChange`) | Installable (`ScriptApp.newTrigger(...)`) |12|---|---|---|13| Registration | Automatic — GAS calls the global function by name | Explicit — created programmatically or via the Triggers UI |14| Runs as | The user currently viewing the file, with **their** authorization | The user who created the trigger, with **that user's** authorization, regardless of who triggers it |15| Can call authorized services | No — no `UrlFetchApp`, no most advanced services, no services needing OAuth consent | Yes — full access to whatever the installing user has authorized |16| Typical use | UI-only reactions (build a menu, format a cell) | Anything that calls an external API, sends email, or must run reliably regardless of viewer permissions |1718If a simple `onEdit` needs to call `UrlFetchApp` or `MailApp`, it will silently fail or throw a permission error — convert it to an installable trigger instead. This is the most common "trigger works when I test it, breaks for other users" bug: the simple trigger works for you (the owner, already authorized) and fails for a viewer/editor without those grants.1920## Event object shapes2122| Trigger | Event object fields |23|---|---|24| `onEdit` | `{ range, oldValue, value, source, user, authMode }` — `oldValue`/`value` are only present for single-cell edits with a scalar value; multi-cell pastes omit them (check `range.getNumRows()/getNumColumns()` instead of assuming `value` exists) |25| `onChange` | `{ changeType, source, user }` — `changeType` is one of `EDIT`, `INSERT_ROW`, `INSERT_COLUMN`, `REMOVE_ROW`, `REMOVE_COLUMN`, `INSERT_GRID`, `REMOVE_GRID`, `FORMAT`, `OTHER` |26| `onFormSubmit` | `{ values, namedValues, response, source, triggerUid }` — `namedValues` maps question titles to arrays (a checkbox question yields multiple values) |27| Time-driven | No event object — the handler runs with no arguments |2829## Quotas3031Execution time limit: 6 minutes per invocation, for both consumer and Google Workspace accounts as of this writing. Trigger-specific caps (total triggers per user/script, total trigger runtime per day) differ by account type and change without notice — don't trust a number pasted into a skill, including this one. Query the live docs instead of the number above:3233```bash34../apps-script-services/scripts/check-quotas.sh "Trigger"35../apps-script-services/scripts/check-quotas.sh "runtime"36```3738(The script lives in the `apps-script-services` skill, which owns the broader quota reference — see that skill if it isn't installed alongside this one.)3940## Registering installable triggers without duplicating them4142`ScriptApp.newTrigger(...).create()` is not idempotent — running the setup code twice creates two triggers calling the same handler, which then runs twice per event. Always check existing triggers by handler function name before creating:4344```ts45function ensureEditTrigger(spreadsheetId: string, handlerFunctionName: string): void {46 const alreadyExists = ScriptApp.getProjectTriggers().some(47 (trigger) => trigger.getHandlerFunction() === handlerFunctionName,48 );49 if (alreadyExists) return;5051 ScriptApp.newTrigger(handlerFunctionName)52 .forSpreadsheet(spreadsheetId)53 .onEdit()54 .create();55}56```5758Run this kind of setup once, from an explicit "install" action (a menu item, `onInstall`, or a setup script) — not from inside the trigger handler itself.5960## Verification6162- [ ] Any handler calling an authorized service (`UrlFetchApp`, `MailApp`, advanced services) is registered as an installable trigger, not left as a simple trigger.63- [ ] Trigger setup code checks `ScriptApp.getProjectTriggers()` before creating a new one.64- [ ] `onEdit`/`onChange` handlers don't assume `event.value` exists — they branch on `event.range` dimensions first.