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):
| Col | Header | What goes in it |
|---|---|---|
| A | Unit | e.g. “12 Oak St, Apt 2B” |
| B | Tenant | A short name or initials is enough (see the privacy tip below) |
| C | Monthly rent | A number, formatted as currency |
| D | Due day | Day of the month rent is due, 1 to 31 (most leases: 1) |
| E | Next rent due | A formula (Step 2), formatted as a date |
| F | Grace days | Days after the due date before a late fee applies, e.g. 5 |
| G | Late fee | The flat fee your lease allows, e.g. 50 |
| H | Paid through | The most recent due date that's been paid, e.g. 11/1/2026. Update it when rent comes in. |
| I | Late fee due | A formula (Step 3) |
| J | Lease end | The last day of the current lease term |
| K | Notice days | How many days before the end notice must be given (from the lease and local law), e.g. 60 |
| L | Notice by | A formula (Step 4), formatted as a date |
| M | Status | Leave blank for occupied units; Vacant, Ended, Paused or Sold stops reminders |
| N | Sent log | Leave 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 to | Custom formula | Color | Meaning |
|---|---|---|---|
| E2:E | =AND(E2<>"",E2-TODAY()<=3) | Yellow | Rent due within 3 days |
| I2:I | =N(I2)>0 | Red | Late fee applies |
| L2:L | =AND(L2<>"",L2>=TODAY(),L2-TODAY()<=60) | Orange | Notice deadline within 60 days |
| L2:L | =AND(L2<>"",L2<TODAY(),J2>=TODAY()) | Red | Notice 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:
- One email a day at most, only when something's due. All units go into one digest, so ten units with rent on the 1st means one email listing ten units, not ten emails.
- Rent: 3 days before and on the due date. Rows whose Paid through already covers the coming due date are skipped, so tenants who pay early don't show up. After the grace period, a unit whose rent isn't marked paid gets one “not marked paid” line for that due date.
- Leases: 60 and 30 days before the notice-by date. If the trigger starts late (say 50 days out), it sends one reminder, not a catch-up pile. Leases that have already ended are ignored; set the Status to Ended or enter the new lease end after a renewal.
- Each reminder goes out once, and every month starts fresh. Sent log entries include the due date they were for (like
R2026-12-01@3), so December's rent gets its own reminders without you clearing anything. The script keeps only the last 12 entries, so the cell stays short. - Short months are handled. Rent due on the 31st is reminded for February 28 (29 in leap years) and then March 31, the same as the sheet formula.
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
- Treat tenant data as private. Keep the sheet shared only with the people who manage the units, and leave out what you don't need for reminders: no Social Security numbers, bank details or full application data. Initials or a unit number are enough for the digest.
- This emails you, not tenants. That's deliberate. If you want to send tenants rent reminders, get their agreement first (many leases or tenant portals cover how you'll contact them), keep the tone neutral, and remember that collection messages can be regulated where you are.
- Check your local rules. Notice periods, grace periods, late-fee caps and how notice must be delivered vary by state, province, city and lease. The formulas here only do date arithmetic. This guide isn't legal advice; when in doubt, ask a local attorney or landlord association.
- Match time zones. Set the script's time zone (Project Settings in Apps Script) to the same zone as the spreadsheet (File › Settings), or a date can land a day off.
- Ownership. The trigger runs as whoever created it, and the emails come from that account. If several people manage the properties, create it from a shared account. Sends count against your Apps Script email quota, which one digest a day doesn't come close to.
- Need receipts for rent payments? See receipts from Google Sheets rows. For repairs and inspections at each property, see maintenance schedule reminders.
See SheetReminders
Short on time? Watch the 50-second version
Related Google Sheets guides
- How to generate a PDF or Google Doc for each row in Google Sheets
- Best Autocrat alternatives for mail merge to PDF and Docs
- Make invoices or certificates from Google Sheets rows
- Create sales quotes or estimates from Google Sheets
- Generate receipts from Google Sheets rows
- Create purchase orders from Google Sheets
- Make packing slips from Google Sheets orders
- Generate a PDF certificate from a Google Form response
- Issue numbered training completion certificates with expiry dates
- Make a PDF progress report for each student
- Generate offer letters from a Google Sheets hiring tracker
- Email each row’s PDF as an attachment
- Automatic email reminders from a date column
- Contract renewal and expiration alerts from Google Sheets
- Track certificate of insurance (COI) expirations in Google Sheets
- Track employee license and certification expirations
- Track equipment and vehicle maintenance with reminders
- Track warranty expiration dates with reminders
- Birthday and work anniversary reminders from Google Sheets
- Rent due and lease renewal reminders from Google Sheets
- Send invoice payment reminders from Google Sheets
- Highlight overdue tasks and send email reminders
- Follow-up reminders for leads, clients and candidates
- Membership renewal reminders for clubs and associations
- SheetWeave vs Autocrat
- SheetWeave vs Document Studio
- SheetWeave vs RowDocs
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.