Automation · Google Sheets

Send due-date email reminders from Google Sheets with Apps Script

To send due-date reminders from Google Sheets, add a “Reminder sent” column, paste the Apps Script below and give it a daily time-driven trigger. Each run emails the rows due within three days, or up to a week late, and writes the date into that column, so a row is not emailed twice. It runs free in your Google account. Botlery builds larger Sheets automations for a free fixed-price quote.

Updated October 2026 · 8 min read ·

Diagram: what one daily run does to the reminder sheet An example sheet after the run on 7 October with a three-day window. Invoice 1042, due 9 October, is emailed and stamped 7 Oct. Annual service, due 20 October, is too early and waits. Deposit, due 6 October, is one day late and is emailed and stamped. Insurance renewal already has a sent date and is skipped. An old invoice due 1 September is more than a week late and is skipped. After the run on 7 October (3-day window) ItemDueSentThis run Invoice 10429 Oct7 OctEmailed today Annual service20 OctToo early: waits Deposit6 Oct7 OctLate: emailed Insurance8 Oct5 OctAlready sent Old invoice1 SepToo old: skipped
Diagram: an example sheet after one run. Green dates are the stamps the script wrote; a row with a date is not picked again.

Invoices that fall due, certificates that expire, services and renewals: most small businesses track these in a sheet and rely on someone remembering to look at it. A short Apps Script can look for you every morning and email whoever needs reminding. The one detail that makes it trustworthy is a “Reminder sent” column, so a reminder goes out once and is never repeated by the next day’s run.

Step 1: set up the sheet

Make a tab called Reminders with these four headers in row 1. They can be in any order, and you can add other columns (notes, amount, owner) anywhere, because the script finds its columns by header name.

ItemEmailDue dateReminder sent
Invoice 1042accounts@client.example9 Oct 2026
Annual boiler servicejo@client.example20 Oct 2026
Insurance certificatesite@subcontractor.example8 Oct 20265 Oct 2026

Step 2: paste the script

Open Extensions › Apps Script, replace the sample code with the script below and save. The settings at the top are the only part you normally change.

Apps Script · Code.gs

/**
 * Due-date reminder emails from a Google Sheet, each row emailed once.
 * From botlery.com/blog/google-sheets-email-reminders-apps-script.html
 *
 * Setup: a tab with the four headers below in row 1, then Extensions > Apps Script,
 * paste this file, run sendDueReminders once (to authorise), then run installDailyTrigger.
 *
 * @OnlyCurrentDoc  Limits the script to this one spreadsheet.
 */
const CONFIG = {
  sheetName: 'Reminders',
  columns: { item: 'Item', email: 'Email', due: 'Due date', sent: 'Reminder sent' },
  daysBefore: 3,       // email when the due date is this many days away, or closer
  maxDaysOverdue: 7,   // skip rows that were already this late when first seen
  runHour: 8,          // the daily trigger fires at some point in this hour
  maxPerRun: 40,       // headroom under Google's daily email quota
  replyTo: '',         // optional: where replies should go, e.g. 'office@yourbusiness.com'
};

function sendDueReminders() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(10000)) return;                 // a previous run is still going
  try {
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const sheet = ss.getSheetByName(CONFIG.sheetName);
    if (!sheet) throw new Error('No tab called "' + CONFIG.sheetName + '"');
    const tz = ss.getSpreadsheetTimeZone();
    const values = sheet.getDataRange().getValues();
    const col = findColumns(values[0]);
    const today = dayNumber(new Date(), tz);
    let sent = 0;

    for (let r = 1; r < values.length; r++) {
      const row = values[r];
      const due = row[col.due];
      const email = String(row[col.email]).trim();
      if (row[col.sent] !== '' || !email || !(due instanceof Date)) continue;  // done, or nothing to send
      const daysLeft = dayNumber(due, tz) - today;
      if (daysLeft > CONFIG.daysBefore || daysLeft < -CONFIG.maxDaysOverdue) continue;
      if (sent >= CONFIG.maxPerRun || MailApp.getRemainingDailyQuota() < 1) break;

      try {
        MailApp.sendEmail(buildEmail(row, col, email, daysLeft, tz));
        sheet.getRange(r + 1, col.sent + 1).setValue(new Date());
        SpreadsheetApp.flush();                     // save the stamp before the next email
        sent++;
      } catch (err) {
        console.error('Row ' + (r + 1) + ' not sent: ' + err.message);  // stays blank, retried tomorrow
      }
    }
    console.log('Reminders sent: ' + sent);
  } finally {
    lock.releaseLock();
  }
}

function buildEmail(row, col, email, daysLeft, tz) {
  const item = String(row[col.item]).trim() || 'Your item';
  const dueText = Utilities.formatDate(row[col.due], tz, 'EEEE d MMMM yyyy');
  let when;
  if (daysLeft > 1) when = 'is due in ' + daysLeft + ' days';
  else if (daysLeft === 1) when = 'is due tomorrow';
  else if (daysLeft === 0) when = 'is due today';
  else when = 'was due ' + (-daysLeft) + (daysLeft === -1 ? ' day' : ' days') + ' ago';
  const mail = {
    to: email,
    subject: 'Reminder: ' + item + ' ' + when,
    body: 'Hello,\n\nA quick reminder: ' + item + ' ' + when + ' (' + dueText + ').\n\n' +
      'If it is already done, please ignore this email.\n',
  };
  if (CONFIG.replyTo) mail.replyTo = CONFIG.replyTo;
  return mail;
}

// Column positions by header name, so columns can be moved or added freely.
function findColumns(headerRow) {
  const names = headerRow.map((h) => String(h).trim().toLowerCase());
  const col = {};
  Object.keys(CONFIG.columns).forEach((key) => {
    col[key] = names.indexOf(CONFIG.columns[key].toLowerCase());
    if (col[key] === -1) throw new Error('Missing column "' + CONFIG.columns[key] + '" in row 1');
  });
  return col;
}

// Whole days since 1970 in the sheet's time zone: daylight-saving days of 23 or 25 hours don't matter.
function dayNumber(date, tz) {
  const ymd = Utilities.formatDate(date, tz, 'yyyy-MM-dd').split('-').map(Number);
  return Date.UTC(ymd[0], ymd[1] - 1, ymd[2]) / 86400000;
}

// Run once. Replaces any earlier copy of the trigger, so it is safe to run again.
function installDailyTrigger() {
  ScriptApp.getProjectTriggers()
    .filter((t) => t.getHandlerFunction() === 'sendDueReminders')
    .forEach((t) => ScriptApp.deleteTrigger(t));
  ScriptApp.newTrigger('sendDueReminders').timeBased().everyDays(1).atHour(CONFIG.runHour).create();
}

// Adds a "Reminders" menu to the sheet for a manual run.
function onOpen() {
  SpreadsheetApp.getUi().createMenu('Reminders')
    .addItem('Send due reminders now', 'sendDueReminders')
    .addItem('Install the daily trigger', 'installDailyTrigger')
    .addToUi();
}

How it decides who gets an email

  1. Rows with anything in Reminder sent, no email address or no real date are skipped.
  2. It counts whole days between today and the due date in the spreadsheet’s own time zone, so a daylight-saving change cannot shift a reminder by a day.
  3. Rows due within daysBefore days, or up to maxDaysOverdue days late, get an email. Everything later waits; anything older is left for a person.
  4. After each email it writes the date into Reminder sent and calls SpreadsheetApp.flush(), so the stamp is saved before the next email goes. If a run is ever cut short, the rows already emailed are already marked.
  5. It stops at maxPerRun emails or when MailApp.getRemainingDailyQuota() reaches zero. The rest go out on the next run.
  6. If one address is wrong, that row stays blank, the error is written to the execution log, and the run carries on with the next row.

Step 3: run it once and authorise

Choose sendDueReminders in the function list and press Run. Google asks for permission to view and edit this spreadsheet and to send email as you. The @OnlyCurrentDoc line at the top keeps the request to this one spreadsheet instead of all of yours. The script uses MailApp, which can only send; GmailApp would also ask to read and delete your mail, which a reminder script has no need for. Check the Execution log for “Reminders sent: N” and look at the sheet: the emailed rows now carry today’s date.

Before that first run, put something in Reminder sent for any old rows you do not want emailed. Anything up to a week overdue goes out on the first run, and the people on the list will see it.

Step 4: schedule it every day

Run installDailyTrigger once. It creates a time-driven trigger that runs sendDueReminders every day in the hour set by runHour, and removes any older copy, so running it twice does not double the emails. You can do the same by hand in the Triggers panel (the clock icon): add a trigger for sendDueReminders, event source Time-driven, type Day timer, and pick the hour.

Do not expect 8:00 sharp. Google picks a time inside the hour you choose and then keeps to it, so 24 hours pass between runs (Google’s installable triggers guide). The hour follows the script’s time zone in Project Settings; set it to match the spreadsheet’s (File › Settings) so “8am” means the same thing in both.

The script also adds a Reminders menu to the sheet, so anyone with edit access can send the day’s reminders on demand.

Google’s limits that apply

From Google’s Quotas for Google Services page, last updated 3 September 2026 and checked on 7 October 2026. Google says the limits can change without notice.

LimitFree Gmail accountGoogle Workspace
Email recipients per day1001,500
Run time per execution6 minutes6 minutes
Total trigger run time per day90 minutes6 hours
Triggers per user per script2020

For a typical reminder list the email quota is the one that matters. A run that sends 40 emails takes seconds, far inside the six-minute limit. If you regularly need more than 100 reminders a day, that is the point where a Workspace account, or a proper email service, earns its cost.

Common changes

Pitfalls

When to hire instead

If your reminders need more than one sheet and one email, it is usually quicker to have it built: reminders that escalate to a manager, a text message as well as an email, a calendar entry for each due date, a tracker shared with clients or subcontractors, or a weekly summary of everything due.

Botlery builds Google Sheets automations in your own Google account for a free fixed-price quote, and you pay once it works on your real sheet.

See Google Sheets automation by Botlery →

Frequently asked questions

How do I stop Google Sheets sending the same reminder twice?

Give the sheet a Reminder sent column and have the script skip any row where that cell is not empty. The script on this page writes the date into the column immediately after each email and calls SpreadsheetApp.flush() so the date is saved before the next email goes out. Tomorrow's run sees the date and skips the row. To send a reminder again, clear the cell. To stop one, type anything into it, such as paid or skip.

How many reminder emails can Apps Script send per day?

Google's published quota is 100 email recipients per day for a free Gmail account and 1,500 for a Google Workspace account, according to Google's Quotas for Google Services page (last updated 3 September 2026, checked 7 October 2026). Every address counts, including cc addresses. MailApp.getRemainingDailyQuota() tells the script how many it has left, and the script on this page stops when it reaches zero and sends the rest on the next run.

Why did my daily reminder arrive at 8:40 instead of 8:00?

Because a time-driven trigger with a day timer runs at a time Google picks inside the hour you chose, and then keeps that time from day to day. Choosing 8am to 9am means some time in that hour, not 8:00 exactly. Also check that the script's time zone in Project Settings matches the spreadsheet's time zone in File, Settings, otherwise the hour is counted in a different zone.

Can the reminder emails come from a shared address like office@?

MailApp sends from the Google account the script runs as, which for the daily run is the account that created the trigger, but you can set a replyTo address so replies go to the office. To send from an alias, use GmailApp.sendEmail with its from option, which only works for an alias already added to that Gmail account under Send mail as. Many businesses simply run the script and its trigger from a shared Google Workspace account, which also keeps the reminders running when staff change.

Can Botlery set up Google Sheets reminders for my business?

Yes. Google Sheets automation is one of Botlery's business automation services. You describe what needs reminding, who should get it and when, and Botlery sends a free fixed-price quote in USD. The script is built in your own Google account on a copy of your sheet, you test it with your real data, and you pay once it works as agreed. You can hire Botlery directly through the contact form or through Upwork.

Rather not maintain a script?

Tell us what you track in Sheets and who needs reminding. You get a free fixed-price quote, and you pay once it works.

Get a free quote Hire on Upwork

Keep reading