A time-driven trigger runs an Apps Script function on a schedule, with nobody having to open the sheet: every 5 minutes, every hour, every morning, every Monday, or on the first of the month. If you have used cron on a server, this is the same idea for Google Sheets. It is how you turn a script into a real automation: a daily email summary, an hourly data refresh, a weekly report, a monthly cleanup.
This post shows how to create time-driven triggers (in the editor and in code), how the schedule options map to cron, a working daily email example, how to avoid duplicate triggers, and the limits and timing rules you need to know. If you are new to Apps Script, start with Google Apps Script for Beginners: A Simple Intro.
In this guide
The short version
- A time-driven trigger is installable. There is no simple version. You create it once, and it then runs on Google’s servers, even with the sheet closed.
- Create it in the editor (Triggers, then Add Trigger) or with
ScriptApp..newTrigger("fn") .timeBased()...create() - Schedules run from every minute to once a month. There is no cron text: you chain
everyMinutes,everyHours,everyDays,onWeekDay,onMonthDay,atHour. - The time is approximate. A 9 AM trigger runs sometime between 9 and 10 AM.
- Create the trigger once. Check for an existing one first, or you will pile up duplicates.
How the results in this post were produced. Apps Script runs only on Google’s servers, so the exact code shown was run against a small simulation of the Sheets service (an in-memory sheet with the same method names). The logic of the script is real, but the sheet and the log are simulated, and the clock was fixed at Monday 16 March 2026, 09:30 India time. Always try a script on a copy of your own sheet first.
How time-driven triggers work
Unlike onEdit and onOpen, there is no function name that Google runs for you. A time-driven trigger is an installable trigger: you tell Apps Script “run the function sendDailySummary every day”, and Google’s servers do it from then on. A few facts that follow from this:
- It runs under the account of the person who created it, and that account is the one whose services and email quota are used.
- It can use services that need authorization, such as sending mail. You approve them once, when you first run the function or create the trigger.
- It runs whether or not anyone has the file open. There is no screen, so calls such as
SpreadsheetApp.getUi()do not work in it. - Only the creator can see the trigger. Another account cannot see triggers installed from your account.
Create one in the editor
The no-code way:
- Open the Apps Script project (Extensions, then Apps Script).
- At the left, click Triggers.
- At the bottom right, click Add Trigger.
- Choose the function to run, and set the event source to Time-driven.
- Pick the type of time-based trigger (every few minutes, hourly, daily, weekly, monthly, or one specific date) and its details.
- Click Save. Google may ask you to approve the script.
The Triggers page also lists every trigger of the project, shows when it ran, and lets you delete or edit it.
Create one in code
The same thing in code uses ScriptApp.newTrigger(), then .timeBased(), then a chain of schedule methods, and finally .create(). These are the methods, from Google’s reference:
| Method | What it does |
|---|---|
| everyMinutes(n) | Every n minutes. n must be 1, 5, 10, 15 or 30 |
| everyHours(n) | Every n hours |
| everyDays(n) | Every n days |
| everyWeeks(n) | Every n weeks. Combine with onWeekDay() |
| onWeekDay(day) | On a day of the week, such as ScriptApp.WeekDay.MONDAY |
| onMonthDay(day) | On a date of the month, 1 to 31 |
| atHour(hour) | In the given hour, 0 to 23. Needs a frequency such as everyDays() |
| nearMinute(minute) | Near the given minute, plus or minus 15 minutes |
| inTimezone(timezone) | Use this time zone. The default is the script's time zone |
| at(date), after(ms) | Once, at a date and time, or after a delay |
Here are six triggers, from every five minutes to the first of the month. In the simulation, the list shows how each one was built:
// Each trigger name must match a function in your project.
function createTriggers() {
ScriptApp.newTrigger("everyFiveMinutes").timeBased().everyMinutes(5).create();
ScriptApp.newTrigger("hourly").timeBased().everyHours(1).create();
ScriptApp.newTrigger("everyMorning").timeBased().everyDays(1).atHour(9).create();
ScriptApp.newTrigger("mondayReport").timeBased().onWeekDay(ScriptApp.WeekDay.MONDAY).atHour(9).create();
ScriptApp.newTrigger("firstOfMonth").timeBased().onMonthDay(1).atHour(6).create();
ScriptApp.newTrigger("morningInIndia").timeBased().everyDays(1).atHour(9).nearMinute(30).inTimezone("Asia/Kolkata").create();
}
Triggers created (simulated)
everyFiveMinutes <- timeBased().everyMinutes(5)
hourly <- timeBased().everyHours(1)
everyMorning <- timeBased().everyDays(1).atHour(9)
mondayReport <- timeBased().onWeekDay(MONDAY).atHour(9)
firstOfMonth <- timeBased().onMonthDay(1).atHour(6)
morningInIndia <- timeBased().everyDays(1).atHour(9).nearMinute(30).inTimezone('Asia/Kolkata')
Note that atHour() and nearMinute() need a frequency, such as everyDays(1), in the chain.
From cron to Apps Script
Apps Script has no cron expressions, but the common ones have a direct equivalent:
| Schedule | Cron | Apps Script |
|---|---|---|
| Every 5 minutes | */5 * * * * | timeBased().everyMinutes(5) |
| Every hour | 0 * * * * | timeBased().everyHours(1) |
| Every 6 hours | 0 */6 * * * | timeBased().everyHours(6) |
| Every day around 9 | 0 9 * * * | timeBased().everyDays(1).atHour(9) |
| Every Monday around 9 | 0 9 * * 1 | timeBased().onWeekDay(ScriptApp.WeekDay.MONDAY).atHour(9) |
| First day of the month | 0 6 1 * * | timeBased().onMonthDay(1).atHour(6) |
| Every 2 weeks on Friday | no plain cron | timeBased().everyWeeks(2).onWeekDay(ScriptApp.WeekDay.FRIDAY) |
| Weekdays only, 9 AM | 0 9 * * 1-5 | A daily trigger, and skip weekends inside the function (see below) |
Two differences to keep in mind. First, the shortest interval is one minute, and a few values only: 1, 5, 10, 15 or 30. Second, the run time is approximate, as explained under timing below.
Example: a daily email summary
Here is the classic automation: every morning, email yourself a summary of overdue tasks. The function reads the Tasks sheet, counts the open and overdue tasks, and sends one email with MailApp. It checks that there is something to report and that quota is left. (We used the same tasks sheet in onOpen in Apps Script; today is 16 March 2026.) Below, the email that the simulation produced:
function sendDailySummary() {
const tz = Session.getScriptTimeZone();
const today = Utilities.formatDate(new Date(), tz, "yyyy-MM-dd");
const rows = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Tasks")
.getDataRange().getValues().slice(1); // drop the header
const open = rows.filter(row => row[1] !== "Done");
const overdue = open.filter(row => Utilities.formatDate(row[2], tz, "yyyy-MM-dd") < today);
if (open.length === 0) return; // nothing to report, send nothing
if (MailApp.getRemainingDailyQuota() < 1) return; // no quota left today
const lines = overdue.map(row => "- " + row[0] + " (due " + Utilities.formatDate(row[2], tz, "yyyy-MM-dd") + ")");
MailApp.sendEmail({
to: Session.getEffectiveUser().getEmail(),
subject: "Daily summary: " + open.length + " open, " + overdue.length + " overdue",
body: "Good morning!\n\nOverdue tasks:\n" + lines.join("\n")
});
}
The email that was sent (simulated)
To: reader@example.com
Subject: Daily summary: 3 open, 2 overdue
Good morning!
Overdue tasks:
- Write report (due 2026-03-10)
- Renew domain (due 2026-03-14)
MailApp only sends email. It cannot read your inbox, which is why it asks for a narrower permission than GmailApp. Google limits how many recipients you can email in a day: 100 for a personal account and 1,500 for Google Workspace at the time of writing, and MailApp tells you how many are left. Then the function is attached to a schedule with everyDays(1).atHour(8), as we do in the next step.
Do not create the same trigger twice
Every time create() runs, it adds another trigger. Run your setup function three times, and the summary email goes out three times every morning. The fix is to check for an existing trigger first, by the name of its handler function. It is also good to have a function that removes the trigger again:
function ensureDailyTrigger() {
const exists = ScriptApp.getProjectTriggers()
.some(t => t.getHandlerFunction() === "sendDailySummary");
if (!exists) {
ScriptApp.newTrigger("sendDailySummary").timeBased().everyDays(1).atHour(8).create();
}
}
function removeDailyTrigger() {
ScriptApp.getProjectTriggers()
.filter(t => t.getHandlerFunction() === "sendDailySummary")
.forEach(t => ScriptApp.deleteTrigger(t));
}
Execution log (simulated)
Triggers after running ensureDailyTrigger() three times: 1
Triggers after removeDailyTrigger(): 0
Run ensureDailyTrigger() once by hand. Never create triggers inside the function that the trigger itself runs, or the count grows every day until it hits the limit of 20 triggers per person per script.
Timing, time zones and the event object
The time is approximate. Google’s documentation says the time might be slightly randomized: if you create a recurring 9 AM trigger, Apps Script chooses a time between 9 AM and 10 AM, and then keeps that timing consistent from day to day. nearMinute() narrows it to plus or minus 15 minutes. So a time-driven trigger is fine for “every morning” but not for “at exactly 09:00:00”.
Time zones. The schedule uses the script’s own time zone, which you can see and change in the project settings (it is the timeZone field of appsscript.json). Use inTimezone() when one trigger needs a different zone.
The event object. A time-driven function receives an event object with the fields year, month, day-of-month, day-of-week (1 is Monday, 7 is Sunday), week-of-year, hour, minute, second and timezone. Names with a hyphen need square brackets. Most jobs ignore it, but it tells you when the run was scheduled. Below, a made-up event with those fields:
function hourlyJob(e) {
Logger.log("Ran at hour %s, day %s of the week (1 = Monday), zone %s", e.hour, e["day-of-week"], e.timezone);
}
Execution log (simulated)
Ran at hour 9, day 1 of the week (1 = Monday), zone Asia/Kolkata
Skipping weekends
There is no “weekdays only” option, so schedule the job daily and let the function decide. Utilities.formatDate() with the pattern EEE gives the short day name in a chosen time zone:
function isWeekday(date, tz) {
const day = Utilities.formatDate(date, tz, "EEE"); // "Mon", "Tue" ... "Sun"
return day !== "Sat" && day !== "Sun";
}
function dailyJob() {
if (!isWeekday(new Date(), Session.getScriptTimeZone())) return; // skip weekends
Logger.log("Working day: the job runs");
}
Execution log (simulated)
Saturday 14 March: false
Sunday 15 March: false
Monday 16 March: true
Working day: the job runs
Limits and quotas
These are the numbers in Google’s documentation at the time of writing (September 2026). Quotas change, so check the current quotas page before you rely on them:
| Limit | Personal (gmail.com) | Google Workspace |
|---|---|---|
| Runtime of one execution | 6 minutes | 6 minutes |
| Total runtime of all triggers per day | 90 minutes | 6 hours |
| Triggers per user per script | 20 | 20 |
| Email recipients per day | 100 | 1,500 |
| Simultaneous executions per user | 30 | 30 |
An add-on can use a time-driven trigger only once per hour at most. If a job is too long for 6 minutes, split it into chunks: process a set number of rows per run and remember where you stopped.
Seeing what happened
A triggered run has no screen, so the place to look is Executions in the left sidebar of the editor. It lists every run with its time, its status and any error, and the log lines that your function wrote with Logger.log(). For runs that fail, the trigger’s settings let you choose to receive failure notifications by email. Before you schedule a function, run it once by hand and approve the permissions it asks for. That is the quickest way to find mistakes.
Common mistakes
- Creating the trigger again and again. Check for an existing one first, or you get duplicate runs and reach the limit of 20.
- Expecting an exact time. A daily trigger at 9 runs sometime between 9 and 10. Do not build anything that depends on the exact minute.
- Wrong time zone. Check the script’s time zone in the project settings, or use
inTimezone(). - Using the screen.
getUi(), alerts and dialogs do not work in a run that nobody is watching. Log or send an email instead. - Runs that are too long. Stay under 6 minutes per run, and remember the total daily trigger runtime.
- Renaming the function. A trigger points to a function by name. If you rename the function, the trigger fails.
- Sending an email per row. Send one summary, not a hundred messages. Your daily quota is small.
- Never testing by hand. Run the function once from the editor first, so that authorization and errors show up right away.
Try it yourself
Work out each answer first, then open the solution.
1. Create a trigger that runs weeklyReport every Monday morning around 8.
Show solution
function createWeeklyTrigger() {
ScriptApp.newTrigger("weeklyReport")
.timeBased()
.onWeekDay(ScriptApp.WeekDay.MONDAY)
.atHour(8)
.create();
}
Trigger created (simulated)
weeklyReport <- timeBased().onWeekDay(MONDAY).atHour(8)It runs on Mondays, in the hour from 8 to 9 (script time zone). The chain onWeekDay(...).atHour(8) is the same one Google shows in its own examples.
2. Create a trigger that runs refreshData every 15 minutes.
Show solution
function createQuarterHourTrigger() {
ScriptApp.newTrigger("refreshData").timeBased().everyMinutes(15).create();
}
Trigger created (simulated)
refreshData <- timeBased().everyMinutes(15)everyMinutes() accepts only 1, 5, 10, 15 or 30.
3. Why is everyMinutes(7) not allowed, and what could you use to run something every 7 minutes?
Show answer
Apps Script only allows 1, 5, 10, 15 or 30 for minute intervals. For 7 minutes, use a 1-minute trigger and let the function check whether enough time has passed since its last run (store the time with PropertiesService). That costs runtime quota, so choose a coarser schedule if you can.
4. A colleague says the daily email arrives twice. What is the first thing to check?
Show answer
Open the Triggers page and look for two triggers for the same function. It usually happens when a setup function that calls newTrigger() was run more than once. Delete the extra one, and add the ensureDailyTrigger() check.
Frequently asked questions
How do I schedule a script in Google Sheets?
Create a time-driven trigger. In the Apps Script editor open Triggers, click Add Trigger, choose your function and the time-driven event source, and pick the interval. Or create one in code with ScriptApp.
Is there a cron in Apps Script?
Not with cron expressions. Time-driven triggers do the same job, and you build the schedule with methods such as everyMinutes(), everyDays(), onWeekDay() and atHour().
How often can an Apps Script trigger run?
As often as every minute, and as rarely as once a month. Minute intervals are limited to 1, 5, 10, 15 or 30. Daily runtime is also limited: 90 minutes for a personal account and 6 hours for Google Workspace, at the time of writing.
Does the trigger run if the spreadsheet is closed?
Yes. Time-driven triggers run on Google’s servers under the account that created them, whether or not the file is open.
Why did my trigger not run at the exact time?
Time-driven triggers are approximate. A trigger set for 9 AM runs sometime between 9 and 10 AM, and nearMinute() gives about plus or minus 15 minutes.
How do I stop a time-driven trigger?
Delete it on the Triggers page, or in code with ScriptApp.getProjectTriggers() and ScriptApp.
Related reading
- onEdit in Apps Script: How It Works, Use Cases – react when a cell changes.
- onOpen in Apps Script: How It Works, Use Cases – menus and messages on open.
- Google Apps Script for Beginners: A Simple Intro – the basics.
- Apps Script Deployment Types Explained – publishing a script for others.