How to Track Rent Due Dates and Lease Renewals in Google Sheets, with Reminders (2026)

A lot of small landlords and property managers run their rentals from one spreadsheet: a row per unit, the tenant, the rent, the day it's due and when the lease ends. The sheet holds all of it, but nothing in it tells you rent is due on Friday or that the deadline to send a non-renewal notice is next month. This guide sets up a Units tab with a next-rent-due date that rolls forward every month (including leases due on the 29th, 30th or 31st), a notice-by date worked out from the lease end, a late-fee column, a “coming up” view, and a free Apps Script that emails you one digest: 3 days before and on each rent due date, and 60 and 30 days before each notice-by date.

Two things make rentals different from a plain contract renewal tracker: rent repeats every month, so the date has to move on its own, and the date that matters for a lease is usually not the end date but the last day you can give notice.

Step 1: a Units tab

Create a tab named Units with these headers in row 1. The script finds columns by name, but the formulas below assume this order (A to N):

ColHeaderWhat goes in it
AUnite.g. “12 Oak St, Apt 2B”
BTenantA short name or initials is enough (see the privacy tip below)
CMonthly rentA number, formatted as currency
DDue dayDay of the month rent is due, 1 to 31 (most leases: 1)
ENext rent dueA formula (Step 2), formatted as a date
FGrace daysDays after the due date before a late fee applies, e.g. 5
GLate feeThe flat fee your lease allows, e.g. 50
HPaid throughThe most recent due date that's been paid, e.g. 11/1/2026. Update it when rent comes in.
ILate fee dueA formula (Step 3)
JLease endThe last day of the current lease term
KNotice daysHow many days before the end notice must be given (from the lease and local law), e.g. 60
LNotice byA formula (Step 4), formatted as a date
MStatusLeave blank for occupied units; Vacant, Ended, Paused or Sold stops reminders
NSent logLeave empty; the script records what it sent

Format E, H, J and L as dates (Format › Number › Date). A date typed as text, like “Nov 1”, won't calculate.

Step 2: a next-rent-due date that rolls every month

In E2, fill down. It takes this month's due date, and once that has passed, next month's. Due days that a month doesn't have (29, 30, 31) land on that month's last day, so rent due on the 31st shows February 28 (or 29 in a leap year), then March 31:

=IF(D2="","",LET(t,TODAY(),
  cur,DATE(YEAR(t),MONTH(t),MIN(D2,DAY(EOMONTH(t,0)))),
  IF(cur>=t,cur,DATE(YEAR(t),MONTH(t)+1,MIN(D2,DAY(EOMONTH(t,1)))))))

About EDATE. EDATE(date,1) also moves a date one month and handles short months: EDATE("1/31/2027",1) is February 28. The catch is chaining it. If each row's due date is EDATE of the previous one, January 31 becomes February 28, and then March 28, April 28 and so on, a few days early for the rest of the lease. Always count from a fixed anchor instead. If you'd rather drive the date from the lease start date (say it's in column P, and its day is the due day), this works without drifting once the lease has started:

=LET(t,TODAY(),n,DATEDIF(P2,t,"M"),d,EDATE(P2,n),IF(d>=t,d,EDATE(P2,n+1)))

Either way, TODAY() recalculates every day, so the date moves to next month the day after rent is due with nobody touching it.

Step 3: grace period and late-fee column

In I2, fill down. It shows the late fee once this month's due date plus the grace days has passed and Paid through doesn't cover it, and 0 otherwise:

=IF(OR(D2="",G2=""),"",LET(t,TODAY(),
  cur,DATE(YEAR(t),MONTH(t),MIN(D2,DAY(EOMONTH(t,0)))),
  IF(AND(t>cur+F2,OR(H2="",H2<cur)),G2,0)))

This is a flag, not a ledger: it only looks at the current month, and it doesn't add up fees from earlier months or partial payments. If you charge daily late fees or need a running balance, keep a separate Payments tab with one row per payment and sum it with SUMIFS.

Step 4: the notice-by date

In L2, fill down. It's the lease end minus the notice period:

=IF(J2="","",J2-K2)

That's the last day to send a renewal offer, a rent-increase notice or a non-renewal notice, whichever your lease and local rules call for. Some places count notice in calendar months, or require it to arrive before the first of a rental period; if so, adjust the formula (for example =EOMONTH(J2,-2) for “before the start of the second-to-last month”) or type the date by hand.

Step 5: what's coming up in the next 30 days

On a new tab, this lists every unit with rent due in the next 30 days, soonest first, skipping vacant units:

=SORT(FILTER({Units!A2:A,Units!B2:B,Units!E2:E,Units!I2:I},
  Units!E2:E<>"",Units!E2:E<=TODAY()+30,Units!M2:M<>"Vacant"),3,TRUE)

And this QUERY lists the leases whose notice-by date falls in the next 30 days (change 30 to 90 to see further ahead):

=QUERY(Units!A1:N,"select A,B,J,L where L is not null and L >= date '"&TEXT(TODAY(),"yyyy-mm-dd")&"' and L <= date '"&TEXT(TODAY()+30,"yyyy-mm-dd")&"' order by L",1)

A FILTER that finds nothing shows #N/A; wrap it in IFERROR(…,"Nothing due") if you like. Format the date columns as dates.

Step 6: conditional formatting

Select the ranges below, open Format › Conditional formatting, pick Custom formula is, and add one rule each:

Apply toCustom formulaColorMeaning
E2:E=AND(E2<>"",E2-TODAY()<=3)YellowRent due within 3 days
I2:I=N(I2)>0RedLate fee applies
L2:L=AND(L2<>"",L2>=TODAY(),L2-TODAY()<=60)OrangeNotice deadline within 60 days
L2:L=AND(L2<>"",L2<TODAY(),J2>=TODAY())RedNotice deadline passed, lease not over yet

Step 7: one reminder email for the whole portfolio

Sheets can't send an email when a date arrives on its own; its notification rules react to edits, not the calendar. A short Apps Script on a daily trigger does it and puts everything in one digest. Open Extensions › Apps Script, paste this, set LANDLORD_EMAIL (or leave it blank to email yourself), save, run sendRentLeaseReminders once to approve permissions, then add a trigger: Triggers › Add trigger › sendRentLeaseReminders › Time-driven › Day timer (6 to 7am works well).

/**
 * Daily rent and lease reminders from a "Units" tab, as one digest email.
 * Headers used (any order): Unit, Due day, Sent log.
 * Optional: Tenant, Grace days, Paid through, Lease end, Notice days, Status.
 * Rent: emails 3 days before and on the due date. Lease: 60 and 30 days
 * before the notice-by date (lease end minus notice days).
 */
const SHEET_NAME     = 'Units';
const LANDLORD_EMAIL = '';          // e.g. 'rentals@yourcompany.com' (blank = you)
const RENT_DAYS      = [3, 0];      // days before rent is due
const NOTICE_DAYS    = [60, 30];    // days before the notice-by date
const STOP_STATUS    = /^(vacant|ended|paused|sold)$/i;

function sendRentLeaseReminders(now) {
  const sh   = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME);
  const rows = sh.getDataRange().getValues();
  const head = rows[0].map(String);
  const col  = (name) => head.indexOf(name);
  const c = {
    unit: col('Unit'), tenant: col('Tenant'), due: col('Due day'),
    grace: col('Grace days'), paid: col('Paid through'),
    end: col('Lease end'), notice: col('Notice days'),
    status: col('Status'), log: col('Sent log'),
  };
  if (c.unit === -1 || c.log === -1 || c.due === -1) {
    throw new Error('Need Unit, Due day and Sent log columns.');
  }
  const tz    = Session.getScriptTimeZone();
  // A time-driven trigger passes an event object, so only use a real Date.
  const today = now instanceof Date ? new Date(now) : new Date();
  today.setHours(0, 0, 0, 0);
  const get   = (row, i) => (i === -1 ? '' : row[i]);
  const ymd   = (d) => Utilities.formatDate(d, tz, 'yyyy-MM-dd');
  const nice  = (d) => Utilities.formatDate(d, tz, 'EEE, MMM d, yyyy');
  const daysTo = (d) => Math.round((d - today) / 86400000);
  const when  = (n) => (n === 0 ? 'today' : n === 1 ? 'tomorrow' : `in ${n} days`);
  const hitOf = (left, list) =>
    left < 0 ? undefined : list.filter((n) => left <= n).sort((a, b) => a - b)[0];
  const lines = [], logs = [];

  for (let r = 1; r < rows.length; r++) {
    const row = rows[r];
    if (!row[c.unit] || STOP_STATUS.test(String(get(row, c.status)).trim())) continue;
    const who  = `${row[c.unit]}${get(row, c.tenant) ? ' (' + get(row, c.tenant) + ')' : ''}`;
    const sent = String(row[c.log] || '').split(';').filter(String);
    const before = sent.length;
    const dueDay = parseInt(row[c.due], 10);
    const paid   = get(row, c.paid) instanceof Date ? get(row, c.paid) : null;

    if (dueDay >= 1 && dueDay <= 31) {
      // 1) Rent coming up: 3 days ahead and on the day, unless already paid.
      const next = nextDue(dueDay, today);
      const left = daysTo(next);
      const hit  = hitOf(left, RENT_DAYS);
      const key  = `R${ymd(next)}@${hit}`;
      if (hit !== undefined && !(paid && paid >= next) && !sent.includes(key)) {
        lines.push(`${who}: rent due ${when(left)} (${nice(next)})`);
        sent.push(key);
      }
      // 2) Grace period over and this month's rent not marked paid: once per due date.
      const cur   = dueIn(today.getFullYear(), today.getMonth(), dueDay);
      const grace = parseInt(get(row, c.grace), 10) || 0;
      const lkey  = `L${ymd(cur)}`;
      if (daysTo(cur) < -grace && !(paid && paid >= cur) && !sent.includes(lkey)) {
        lines.push(`${who}: rent due ${nice(cur)} not marked paid; grace period ended`);
        sent.push(lkey);
      }
    }

    // 3) Lease renewal or non-renewal notice: 60 and 30 days before notice-by.
    const end = get(row, c.end);
    if (end instanceof Date && end >= today) {
      const by   = new Date(end);
      by.setDate(by.getDate() - (parseInt(get(row, c.notice), 10) || 0));
      const left = daysTo(by);
      const hit  = hitOf(left, NOTICE_DAYS);
      const key  = `N${ymd(by)}@${hit}`;
      if (hit !== undefined && !sent.includes(key)) {
        lines.push(`${who}: lease ends ${nice(end)}; decide and send notice by ` +
                   `${nice(by)} (${when(left)})`);
        sent.push(key);
      }
    }
    if (sent.length > before) logs.push([r + 1, sent.slice(-12).join(';')]);
  }

  if (!lines.length || MailApp.getRemainingDailyQuota() < 1) return;
  const to = LANDLORD_EMAIL || Session.getEffectiveUser().getEmail();
  MailApp.sendEmail(to, `Rent and lease reminders (${lines.length})`,
    `Coming up:\n\n- ${lines.join('\n- ')}\n\nSent from the Units sheet.`);
  logs.forEach(([r, v]) => sh.getRange(r, c.log + 1).setValue(v));
}

// The rent due date in a given month; day 29-31 becomes the last day
// of shorter months (Jan 31 -> Feb 28 or 29 -> Mar 31).
function dueIn(year, month, day) {
  const last = new Date(year, month + 1, 0).getDate();
  return new Date(year, month, Math.min(day, last));
}

// The next due date on or after today.
function nextDue(day, today) {
  const d = dueIn(today.getFullYear(), today.getMonth(), day);
  return d >= today ? d : dueIn(today.getFullYear(), today.getMonth() + 1, day);
}

How it behaves:

We mock-tested this logic before publishing: the first run sent one digest with the right six lines, a second run the same day sent nothing, the next month started a new cycle, and a due day of 31 rolled to February 28 in 2027 and February 29 in 2028.

Tips

Want reminders without maintaining a script? SheetReminders (disclosure: we make it) is a Google Sheets add-on that watches a date column and emails the right person before it arrives. Point it at the Next rent due column from Step 2 (which always holds the upcoming date) and the Notice by column from Step 4: several reminders per date (like 3 and 0 days, or 60 and 30), personalized messages with {{Column name}} and {{Days left}}, a digest mode with one email per person, skipping rows marked Done, and a sent log written back to the row. SheetReminders works on dates, so the late-fee math stays in your sheet's formulas (Step 3) or the script above. Sheet data stays inside your Google account and it uses narrow scopes. 14-day free trial, no credit card required. It is live on the Google Workspace Marketplace.
See SheetReminders
Short on time? Watch the 50-second version

Related Google Sheets guides

Instructions refer to Google Sheets, Google Docs, Google Forms, Google Drive and Apps Script as they worked on November 12, 2026. Not affiliated with Google or any other vendor named here. Google Sheets, Google Docs, Google Forms, Google Slides and Google Drive are trademarks of Google LLC. Features change, so verify in your own account. Questions or corrections: hello@loomwise.app.