Install time-driven triggers safely
Install and remove repeat-safe time-driven triggers for a bound Apps Script request tracker, without creating duplicate clock triggers.
Install a single hourly trigger that updates blank request statuses without creating a duplicate matching clock trigger visible to the designated installer.
Install one hourly trigger that changes blank request statuses to Ready, then remove it. The handler runs the same logic whether you click Run in the editor or let the clock schedule invoke it.
Choose what starts the function
A handler is the function Apps Script calls when an event occurs:
| Connection | What starts it | Authorization and useful boundary |
|---|---|---|
Simple trigger, such as onOpen or onEdit | A reserved function name in a bound project; no installer | Runs in a restricted context. It can work with its bound file, but cannot send Gmail messages or access other files. An ordinary Sheets simple trigger has a 30-second limit. |
| Installable event trigger | A trigger created in Triggers or via ScriptApp.newTrigger(...) for an open, edit, change, or other supported event | Runs as its creator, with that account's granted access. The user who edits the sheet is not necessarily the account executing the code. |
| Installable time-driven trigger | A saved clock schedule | Runs as its creator without a person opening the spreadsheet. Closing your browser does not stop it. |
The next chapter uses the simple onOpen trigger to build a custom menu. For a recurring background scan, use an installable time-driven trigger.
Sheets edit triggers fire only on user edits in the spreadsheet UI, not on programmatic setValues() writes or API calls, and simple onEdit queues at most two concurrent events. See simple triggers and installable-trigger restrictions.
Start with a separate disposable spreadsheet
Create a new spreadsheet for safe-request-status-trigger. Because all .gs files in a project share one global scope and installed triggers remain attached to their project until removed, keep each chapter's project in its own spreadsheet.
Create a new spreadsheet and name its data tab
Requests.Copy
sample.csvbelow intoA1, select column A, and choose Data > Split text to columns with Comma as the separator. Confirm that the table occupies four rows by four columns (A1:D4).Open Extensions > Apps Script from that spreadsheet and add
Code.gsandTriggerHelpers.gs. A trailing underscore on a function name (getRequestsSheet_) hides the helper from the editor's Run menu and trigger picker while keeping it callable across files in the project.In Project Settings, enable Show "appsscript.json" manifest file in editor, replace
appsscript.jsonwith the manifest below, and save the project.
Request ID,Requester,Summary,Status
R-200,Alex,Review the meeting notes,
R-201,Sam,Update the project brief,Hold
R-202,Jo,Prepare a draft agenda,
const REQUEST_SHEET_NAME_ = 'Requests';
const REQUEST_HEADER_ = ['Request ID', 'Requester', 'Summary', 'Status'];
const REQUEST_MAX_ROWS_ = 100;
/**
* Runs the request update from a time-driven trigger or manually.
*
* @returns {void}
*/
function runScheduledRequestUpdate() {
const lock = LockService.getScriptLock();
if (!lock.tryLock(5000)) {
Logger.log('Skipped request update because another execution is running.');
return;
}
try {
const sheet = getRequestSheet_();
const values = readAndValidateRequests_(sheet);
if (values.length === 1) {
Logger.log('No request rows to update.');
return;
}
const statuses = values.slice(1).map(function (row) {
return [row[3] === '' ? 'Ready' : row[3]];
});
const changed = statuses.filter(function (status, index) {
return status[0] !== values[index + 1][3];
}).length;
if (changed === 0) {
Logger.log('No blank request statuses found.');
return;
}
sheet.getRange(2, 4, statuses.length, 1).setValues(statuses);
SpreadsheetApp.flush();
Logger.log('Marked ' + changed + ' request(s) Ready.');
} finally {
lock.releaseLock();
}
}
/**
* @returns {SpreadsheetApp.Sheet}
*/
function getRequestSheet_() {
const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
if (!spreadsheet) {
throw new Error('Open this project from its bound spreadsheet, not a standalone project.');
}
const sheet = spreadsheet.getSheetByName(REQUEST_SHEET_NAME_);
if (!sheet) {
throw new Error('Requests sheet is missing. Import sample.csv into a sheet named Requests.');
}
return sheet;
}
/**
* @param {SpreadsheetApp.Sheet} sheet
* @returns {Array<Array<*>>}
*/
function readAndValidateRequests_(sheet) {
const range = sheet.getDataRange();
if (range.getNumRows() > REQUEST_MAX_ROWS_ + 1 || range.getNumColumns() > REQUEST_HEADER_.length) {
throw new Error('Requests must fit within A1:D101 (100 request rows). Move other content to another tab.');
}
const values = range.getValues();
const header = values[0];
if (
header.length !== REQUEST_HEADER_.length ||
header.some(function (value, index) {
return value !== REQUEST_HEADER_[index];
})
) {
throw new Error('Requests must use this header row: Request ID, Requester, Summary, Status.');
}
values.slice(1).forEach(function (row, index) {
if (row.length !== REQUEST_HEADER_.length || !row[0] || !row[1] || !row[2]) {
throw new Error('Request row ' + (index + 2) + ' is incomplete.');
}
if (typeof row[3] === 'string' && row[3].startsWith('=')) {
throw new Error('Request row ' + (index + 2) + ' has a Status value beginning with =.');
}
});
if (values.length > 1) {
const output = sheet.getRange(2, 4, values.length - 1, 1);
if (output.isPartOfMerge()) {
throw new Error('Status cells must not be merged.');
}
if (output.getFormulas().some(function (row) { return row[0] !== ''; })) {
throw new Error('Status cells must contain values, not formulas.');
}
}
return values;
}
const SCHEDULE_HANDLER_ = 'runScheduledRequestUpdate';
/**
* Installs one hourly trigger for the request-status runner.
* Stops on visible conflicts; preserves one existing clock trigger without changing its cadence.
*
* @returns {void}
*/
function installRequestStatusTrigger() {
const lock = LockService.getScriptLock();
if (!lock.tryLock(5000)) {
throw new Error('Could not acquire the trigger installation lock. Try again shortly.');
}
try {
readAndValidateRequests_(getRequestSheet_());
const existing = ScriptApp.getProjectTriggers().filter(function (trigger) {
return trigger.getHandlerFunction() === SCHEDULE_HANDLER_;
});
if (existing.some(function (trigger) {
return trigger.getEventType() !== ScriptApp.EventType.CLOCK ||
trigger.getTriggerSource() !== ScriptApp.TriggerSource.CLOCK;
})) {
throw new Error('A non-clock trigger uses runScheduledRequestUpdate. Review it in Triggers before installing.');
}
if (existing.length > 1) {
throw new Error('Multiple request-status clock triggers exist. Review them, then run removeRequestStatusTriggers before installing again.');
}
if (existing.length === 1) {
Logger.log('Trigger already installed: ' + existing[0].getUniqueId());
Logger.log('Confirm its hourly interval in Triggers. This installer does not change an existing schedule.');
return;
}
const trigger = ScriptApp.newTrigger(SCHEDULE_HANDLER_).timeBased().everyHours(1).create();
Logger.log('Installed trigger: ' + trigger.getUniqueId());
} finally {
lock.releaseLock();
}
}
/**
* Removes all visible time-driven triggers for the request-status runner.
* A different account's installable triggers are not visible or removable here.
*
* @returns {void}
*/
function removeRequestStatusTriggers() {
const lock = LockService.getScriptLock();
if (!lock.tryLock(5000)) {
throw new Error('Could not acquire the trigger removal lock. Try again shortly.');
}
try {
const triggers = ScriptApp.getProjectTriggers().filter(function (trigger) {
return trigger.getHandlerFunction() === SCHEDULE_HANDLER_ &&
trigger.getEventType() === ScriptApp.EventType.CLOCK &&
trigger.getTriggerSource() === ScriptApp.TriggerSource.CLOCK;
});
triggers.forEach(function (trigger) {
ScriptApp.deleteTrigger(trigger);
});
Logger.log('Removed ' + triggers.length + ' request-status trigger(s).');
} finally {
lock.releaseLock();
}
}
{
"timeZone": "Etc/UTC",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/spreadsheets.currentonly",
"https://www.googleapis.com/auth/script.scriptapp"
]
}
The manifest sets the V8 runtime and Etc/UTC time zone, requests spreadsheets.currentonly to read and write the bound spreadsheet, and requests script.scriptapp to list, create, and delete project triggers.
Run the update before installing anything
Select runScheduledRequestUpdate in the editor and click Run, authorizing the project when prompted. With the initial fixture, expect these changes:
| Request | Before | After the first successful run |
|---|---|---|
R-200, row 2 | blank | Ready |
R-201, row 3 | Hold | Hold |
R-202, row 4 | blank | Ready |
The execution log prints Marked 2 request(s) Ready. Running runScheduledRequestUpdate a second time logs No blank request statuses found. without writing to the sheet, while columns A:C remain unchanged. A header-only table logs No request rows to update.
After reading the used range once with getValues(), runScheduledRequestUpdate skips the header row with slice(1), substitutes 'Ready' for any blank status (row[3] === ''), counts rows whose status changed, and writes the updated status column with a single setValues() call.
Before computing those updates, the helper validates the sheet:
The script must be bound to a spreadsheet containing a
Requeststab. In a bound trigger execution,getActiveSpreadsheet()returns that container spreadsheet regardless of which browser tab is open.The used range must fit within
A1:D101(one header row plus up to 100 request rows). The helper checks row and column counts before callinggetValues(), then verifies the exact header labels and checks that every row (including filtered or hidden rows) has non-emptyRequest ID,Requester, andSummaryvalues.The status column (
D2:D) must contain no merged cells, no formulas (checked with a singlegetFormulas()read), and no text strings starting with=, whichsetValues()would interpret as formulas.
The 100-row cap bounds the example's runtime; if the sheet exceeds 101 rows, the function throws before reading values. Required fields in columns A:C are checked with !value (which rejects empty strings, 0, and false), whereas row[3] === '' matches only an empty string and leaves non-empty statuses such as Hold or Waiting untouched.
Understand the event argument
When a trigger fires, Apps Script passes an event object as the first argument (conventionally named e) to the handler function:
| Event | Available context to use |
|---|---|
| Sheets open | e.source is the Spreadsheet; there is no edited range. |
| Sheets edit | e.source and e.range identify the spreadsheet and edited range. e.value and e.oldValue are populated only for single-cell edits, and e.oldValue is undefined when the cell was previously blank. |
| Sheets change | e.changeType identifies structural changes such as INSERT_ROW and does not include e.range. |
| Clock | Time fields such as e.hour, e.timezone, and e['day-of-month'] (bracket notation is required for hyphenated keys), plus e.triggerUid; no spreadsheet range is included. |
e.authMode indicates the authorization level, and e.user may be empty depending on the trigger type and domain settings. Installable triggers include e.triggerUid, which identifies the saved trigger configuration rather than a single firing.
Because clicking Run in the editor passes undefined instead of an event object, runScheduledRequestUpdate takes no event parameter and reads the Requests table directly on both manual and clock-driven runs.
Install one visible clock schedule
An installable trigger runs under the account that created it, and transferring ownership of the spreadsheet in Drive does not transfer its installed triggers. Using the account that should own the schedule:
Run
installRequestStatusTrigger.Verify that the execution log prints
Installed trigger:followed by the new trigger UID. The installer validates theRequeststable before creating the trigger without modifying any cells.Open Triggers in the left sidebar and confirm that one time-driven, hourly trigger is registered for
runScheduledRequestUpdate.Run
installRequestStatusTriggera second time under the same account. It logsTrigger already installed:with the existing UID and leaves the single clock trigger in place.
ScriptApp.newTrigger('runScheduledRequestUpdate').timeBased().everyHours(1).create() builds and registers the schedule. Clock triggers fire at approximate intervals rather than exact wall-clock seconds; calendar-time builders (atHour, nearMinute) use the script time zone unless overridden. See ClockTriggerBuilder.
Before inspecting or creating triggers, the installer acquires the script lock shared by the runner and remover. It then calls ScriptApp.getProjectTriggers() to list the current user's installable triggers in this project, filters by getHandlerFunction() === 'runScheduledRequestUpdate', and branches on the count:
Visible matches for runScheduledRequestUpdate | Installer action |
|---|---|
| None | Create one hourly clock trigger. |
| One clock trigger | Preserve it and log its ID. |
| More than one clock trigger | Throw; ask the owner to review and remove these schedules before reinstalling. |
| Any non-clock trigger for the same handler | Throw; ask the owner to review that conflicting connection in Triggers. |
The Trigger class exposes the handler name, event type, event source, and UID, but provides no method to read back a clock trigger's hourly interval, and returns null from getTriggerSourceId() on clock triggers. Because getProjectTriggers() lists only triggers owned by the current execution user, the script lock prevents duplicate schedules only within a single installer account; a collaborator running installRequestStatusTrigger under their own account would create a second schedule. If create() fails with an uncertain error, check Triggers before re-running the installer to see whether the trigger was already created.
Interpret overlaps and failures
The runner, installer, and remover all call LockService.getScriptLock().tryLock(5000). If a scheduled run cannot acquire the lock within 5,000 milliseconds, it logs Skipped request update because another execution is running. and returns without reading or writing the sheet, whereas the installer and remover throw an error so a failed management command is visible.
Inside the try block, runScheduledRequestUpdate calls SpreadsheetApp.flush() immediately after setValues() so pending spreadsheet writes are applied before finally releases the lock, as recommended by the Lock reference.
Because runScheduledRequestUpdate updates only cells where Status is '', repeated runs after a row reaches Ready make no further changes. If an unattended run fails, inspect Executions under the trigger owner's account. Current Apps Script quotas allow up to 20 triggers per user per script, 6 minutes per execution, and 90 minutes (consumer) or 6 hours (Google Workspace) of daily trigger runtime.
Remove the schedule and restore the exercise
Run removeRequestStatusTriggers under the account that installed the schedule. It deletes only the current user's clock triggers for runScheduledRequestUpdate in this project while leaving other handlers and non-clock triggers untouched. Open Triggers to confirm the entry is gone, then run removeRequestStatusTriggers a second time; the logged removal count should be 1 on the first run and 0 on the second.
With the schedule removed, verify how the handler treats non-blank statuses and validation errors:
Starting from the first-run result (
D2andD4set toReady,D3set toHold), changeD3fromHoldtoWaitingand runrunScheduledRequestUpdate. It logsNo blank request statuses found.and leavesWaitingintact.Clear
D3and runrunScheduledRequestUpdateagain. It logsMarked 1 request(s) Ready.and setsD3toReady. RestoreD3toHold.Clear
D2, then clearC4(Prepare a draft agenda) and runrunScheduledRequestUpdate. Validation throwsRequest row 4 is incomplete.before any write occurs, leavingD2blank. RestoreC4toPrepare a draft agendaand runrunScheduledRequestUpdateonce to markR-200Ready.Clear
D2andD4while leavingD3asHoldto return the table to its initial state.
Next, combine validation, state, and triggers into a complete Sheets request tracker.
Project files
Complete source files for this chapter’s examples.
Install a safe request-status trigger
Code.gs
Download fileconst REQUEST_SHEET_NAME_ = 'Requests';
const REQUEST_HEADER_ = ['Request ID', 'Requester', 'Summary', 'Status'];
const REQUEST_MAX_ROWS_ = 100;
/**
* Runs the request update from a time-driven trigger or manually.
*
* @returns {void}
*/
function runScheduledRequestUpdate() {
const lock = LockService.getScriptLock();
if (!lock.tryLock(5000)) {
Logger.log('Skipped request update because another execution is running.');
return;
}
try {
const sheet = getRequestSheet_();
const values = readAndValidateRequests_(sheet);
if (values.length === 1) {
Logger.log('No request rows to update.');
return;
}
const statuses = values.slice(1).map(function (row) {
return [row[3] === '' ? 'Ready' : row[3]];
});
const changed = statuses.filter(function (status, index) {
return status[0] !== values[index + 1][3];
}).length;
if (changed === 0) {
Logger.log('No blank request statuses found.');
return;
}
sheet.getRange(2, 4, statuses.length, 1).setValues(statuses);
SpreadsheetApp.flush();
Logger.log('Marked ' + changed + ' request(s) Ready.');
} finally {
lock.releaseLock();
}
}
/**
* @returns {SpreadsheetApp.Sheet}
*/
function getRequestSheet_() {
const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
if (!spreadsheet) {
throw new Error('Open this project from its bound spreadsheet, not a standalone project.');
}
const sheet = spreadsheet.getSheetByName(REQUEST_SHEET_NAME_);
if (!sheet) {
throw new Error('Requests sheet is missing. Import sample.csv into a sheet named Requests.');
}
return sheet;
}
/**
* @param {SpreadsheetApp.Sheet} sheet
* @returns {Array<Array<*>>}
*/
function readAndValidateRequests_(sheet) {
const range = sheet.getDataRange();
if (range.getNumRows() > REQUEST_MAX_ROWS_ + 1 || range.getNumColumns() > REQUEST_HEADER_.length) {
throw new Error('Requests must fit within A1:D101 (100 request rows). Move other content to another tab.');
}
const values = range.getValues();
const header = values[0];
if (
header.length !== REQUEST_HEADER_.length ||
header.some(function (value, index) {
return value !== REQUEST_HEADER_[index];
})
) {
throw new Error('Requests must use this header row: Request ID, Requester, Summary, Status.');
}
values.slice(1).forEach(function (row, index) {
if (row.length !== REQUEST_HEADER_.length || !row[0] || !row[1] || !row[2]) {
throw new Error('Request row ' + (index + 2) + ' is incomplete.');
}
if (typeof row[3] === 'string' && row[3].startsWith('=')) {
throw new Error('Request row ' + (index + 2) + ' has a Status value beginning with =.');
}
});
if (values.length > 1) {
const output = sheet.getRange(2, 4, values.length - 1, 1);
if (output.isPartOfMerge()) {
throw new Error('Status cells must not be merged.');
}
if (output.getFormulas().some(function (row) { return row[0] !== ''; })) {
throw new Error('Status cells must contain values, not formulas.');
}
}
return values;
}
TriggerHelpers.gs
Download fileconst SCHEDULE_HANDLER_ = 'runScheduledRequestUpdate';
/**
* Installs one hourly trigger for the request-status runner.
* Stops on visible conflicts; preserves one existing clock trigger without changing its cadence.
*
* @returns {void}
*/
function installRequestStatusTrigger() {
const lock = LockService.getScriptLock();
if (!lock.tryLock(5000)) {
throw new Error('Could not acquire the trigger installation lock. Try again shortly.');
}
try {
readAndValidateRequests_(getRequestSheet_());
const existing = ScriptApp.getProjectTriggers().filter(function (trigger) {
return trigger.getHandlerFunction() === SCHEDULE_HANDLER_;
});
if (existing.some(function (trigger) {
return trigger.getEventType() !== ScriptApp.EventType.CLOCK ||
trigger.getTriggerSource() !== ScriptApp.TriggerSource.CLOCK;
})) {
throw new Error('A non-clock trigger uses runScheduledRequestUpdate. Review it in Triggers before installing.');
}
if (existing.length > 1) {
throw new Error('Multiple request-status clock triggers exist. Review them, then run removeRequestStatusTriggers before installing again.');
}
if (existing.length === 1) {
Logger.log('Trigger already installed: ' + existing[0].getUniqueId());
Logger.log('Confirm its hourly interval in Triggers. This installer does not change an existing schedule.');
return;
}
const trigger = ScriptApp.newTrigger(SCHEDULE_HANDLER_).timeBased().everyHours(1).create();
Logger.log('Installed trigger: ' + trigger.getUniqueId());
} finally {
lock.releaseLock();
}
}
/**
* Removes all visible time-driven triggers for the request-status runner.
* A different account's installable triggers are not visible or removable here.
*
* @returns {void}
*/
function removeRequestStatusTriggers() {
const lock = LockService.getScriptLock();
if (!lock.tryLock(5000)) {
throw new Error('Could not acquire the trigger removal lock. Try again shortly.');
}
try {
const triggers = ScriptApp.getProjectTriggers().filter(function (trigger) {
return trigger.getHandlerFunction() === SCHEDULE_HANDLER_ &&
trigger.getEventType() === ScriptApp.EventType.CLOCK &&
trigger.getTriggerSource() === ScriptApp.TriggerSource.CLOCK;
});
triggers.forEach(function (trigger) {
ScriptApp.deleteTrigger(trigger);
});
Logger.log('Removed ' + triggers.length + ' request-status trigger(s).');
} finally {
lock.releaseLock();
}
}
appsscript.json
Download file{
"timeZone": "Etc/UTC",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/spreadsheets.currentonly",
"https://www.googleapis.com/auth/script.scriptapp"
]
}
sample.csv
Download fileRequest ID,Requester,Summary,Status
R-200,Alex,Review the meeting notes,
R-201,Sam,Update the project brief,Hold
R-202,Jo,Prepare a draft agenda,