Blog / Construction Operations
Punch List in Google Sheets with Photo Links and Owner Reminders
By Keyona Meeks, Founder, ReRev Labs · Oct 31, 2026 · 10 min read
The short answer: A punch list is the list of small items left to finish or fix before a job is closed out. In Google Sheets, give each item a number, a location, a description, a responsible party, a photo link, a due date and a status. A short Google Apps Script then emails you a digest of overdue open items each weekday morning, and a list of items marked Fixed that you still have to check. The default sends only to you. A setting sends each party their own list. This post tracks the list. It does not tell you what your contract requires. I mock-tested the script; I did not run it in a live Google account.
By the end of this post, you'll be able to:
- Set up a punch list sheet with the columns that matter.
- Add a days-open formula and a status dropdown.
- Paste in a script that sends overdue reminders without nagging.
- Know what a sheet cannot decide for you.
The walkthrough ends. Someone has a phone full of photos and a half-remembered list. Three days later a sub says they fixed something that you cannot find a note about. Two weeks later the owner asks what is left. The list itself is easy. The part that slips is who owns each item, when it was due and who checks it after it is fixed.
I sell build work, so you should know I benefit if you hire help. The sheet and script below are free to copy, and most small offices can run them without us.
What is a punch list?
It is the list of items that remain to be completed or corrected near the end of a job, usually found on a walkthrough. Each item is small: a scratch, a missing cover plate, a door that sticks. The list matters because closeout and final payment often depend on it, and your contract decides how.
When I searched "punch list template google sheets" on October 30, 2026, page one was mostly template pages, software vendor pages and a few blog posts. Titles only. I did not review them and I name none. A template gives you the columns. It does not send a reminder when an item is late or tell you what is waiting for your check.
Which written rule can you read as an example?
Private jobs follow your contract. Federal work has a clause you can read for free, and it shows how a written standard can tie punch list items to completion. The General Services Administration clause on substantial completion says the work is "substantially complete" only if the contractor has completed it "such that the Government may enjoy the intended access, occupancy, possession, and use of the entire work" and later refers to the contractor having "corrected all deficiencies and all punch list items, and complied with all conditions for final payment." Source: GSAM 552.211-70, Substantial Completion, clause dated Mar 2019 on the page I read, checked October 30, 2026.
That clause applies to GSA's own contracts. It is an example of how a contract can word it, not the rule for your job. What counts as substantial completion, who inspects, how long you have and what happens to payment are set by your contract and your state. Check with an attorney if you are unsure.
What columns does the sheet need?
Make a tab called Punch List with these headers in row 1. The names must match exactly:
| Column | What goes in it |
|---|---|
| Item | A running number. |
| Project | The job name. |
| Location | Room, floor or area. |
| Description | What needs to be done, in one line. |
| Responsible | Who fixes it. Use the same spelling every time. |
| Photo link | A link to the photo, for example in a Drive folder. |
| Date added | The day you found it. |
| Due date | The day it should be done. |
| Status | Open, Fixed or Verified. |
| Last reminded | Leave blank. The script fills it. |
| Days open | A formula, below. |
The statuses carry the process. Open means someone owes work. Fixed means they say it is done and you still have to look. Verified means you looked. A dropdown for Status stops typos, and Google's help covers adding one.
Paste this into K2 and copy it down:
=IF(OR(A2="",I2="Verified"),"",TODAY()-G2)
It shows how many days an item has been open, and goes blank once it is verified. I tested it in LibreOffice.
Photos are links, not files in the sheet. Put the photos in a Drive folder and paste each link. Check who can open the folder, because photos of a site can show people, addresses and property.
How do the reminders work?
Add a tab called Contacts with two columns, Name and Email. The names must match the Responsible column.
Each weekday morning the script:
- Looks for overdue open items. Status
Openand a due date before today. - Skips items it reminded about recently. By default, anything reminded in the last 3 days.
- Looks for items marked
Fixed. These go on your list to check, also not more often than every 3 days. - Emails you one digest. Grouped by responsible party, with the photo link and how many days late.
- Stamps
Last reminded. So the same item does not appear every morning.
By default, only you get an email. This is deliberate. A reminder to a sub is an email in your name to someone outside your company, so you decide. Set EMAIL_RESPONSIBLE_PARTIES to true and each party with an email in Contacts also gets their own list. A party with no address on file gets nothing, and you still see them in your digest. If your team is not sure who should be reminded, start with the default.
How do you set up the script?
- Open the sheet. Choose Extensions, then Apps Script. If this is new to you, read what Google Apps Script is first.
- Delete the placeholder code and paste the script below.
- Change
OWNER_EMAILto your address. - Click Save.
- In the function list, choose
setupPunchTriggerand click Run. Google asks you to allow the script to read your sheet and send email as you. Read the list. Approve it only if you agree.
// Punch list reminders: email a digest of overdue open items, and items waiting for your check.
// Paste into Extensions > Apps Script in the sheet that holds your Punch List tab.
const CONFIG = {
SHEET_NAME: 'Punch List',
CONTACTS_SHEET: 'Contacts', // columns: Name, Email (Name must match the Responsible column)
OWNER_EMAIL: 'you@example.com', // you always get a digest
EMAIL_RESPONSIBLE_PARTIES: false, // false: only you get emails. true: each party also gets their own.
REMIND_EVERY_DAYS: 3, // do not remind about the same item more often than this
OPEN_STATUS: 'Open',
FIXED_STATUS: 'Fixed' // the other party says it is done; you still have to check
};
const NEEDED = ['Item', 'Project', 'Location', 'Description', 'Responsible', 'Photo link',
'Date added', 'Due date', 'Status', 'Last reminded'];
function sendPunchReminders() {
const today = startOfDay_(new Date());
const day = today.getDay();
if (day === 0 || day === 6) return; // weekdays only
const sheet = SpreadsheetApp.getActive().getSheetByName(CONFIG.SHEET_NAME);
const values = sheet.getDataRange().getValues();
const col = {};
NEEDED.forEach(function (n) {
const i = values[0].indexOf(n);
if (i === -1) throw new Error('Missing column: ' + n);
col[n] = i;
});
const overdue = {}; // responsible party -> items to remind about
const toCheck = []; // items marked Fixed and waiting for you
const remindedRows = [];
for (let r = 1; r < values.length; r++) {
const row = values[r];
if (!row[col['Item']]) continue;
const status = String(row[col['Status']]).trim();
const who = String(row[col['Responsible']]).trim() || 'Unassigned';
const item = describe_(row, col);
if (status === CONFIG.OPEN_STATUS) {
const due = row[col['Due date']];
if (!(due instanceof Date)) continue;
if (startOfDay_(due) >= today) continue; // not overdue yet
const last = row[col['Last reminded']];
if (last instanceof Date && daysBetween_(startOfDay_(last), today) < CONFIG.REMIND_EVERY_DAYS) continue;
item.daysLate = daysBetween_(startOfDay_(due), today);
(overdue[who] = overdue[who] || []).push(item);
remindedRows.push(r + 1);
} else if (status === CONFIG.FIXED_STATUS) {
const last = row[col['Last reminded']];
if (last instanceof Date && daysBetween_(startOfDay_(last), today) < CONFIG.REMIND_EVERY_DAYS) continue;
toCheck.push(item);
remindedRows.push(r + 1);
}
}
const parties = Object.keys(overdue);
if (!parties.length && !toCheck.length) return; // nothing to say
// The owner always gets one digest.
MailApp.sendEmail(CONFIG.OWNER_EMAIL, 'Punch list: ' + countItems_(overdue) + ' overdue, ' +
toCheck.length + ' to check', ownerBody_(overdue, toCheck));
if (CONFIG.EMAIL_RESPONSIBLE_PARTIES) {
const emails = contactEmails_();
parties.forEach(function (who) {
const to = emails[who.toLowerCase()];
if (!to) return; // no address on file: owner digest only
MailApp.sendEmail(to, 'Punch list items overdue', partyBody_(who, overdue[who]));
});
}
remindedRows.forEach(function (rowNum) {
sheet.getRange(rowNum, col['Last reminded'] + 1).setValue(new Date());
});
}
function describe_(row, col) {
return {
item: row[col['Item']], project: row[col['Project']], location: row[col['Location']],
description: row[col['Description']], photo: row[col['Photo link']], who: row[col['Responsible']]
};
}
function line_(it) {
return '#' + it.item + ' | ' + it.project + ' | ' + it.location + ': ' + it.description +
(it.daysLate ? ' (' + it.daysLate + ' days late)' : '') + (it.photo ? '\n Photo: ' + it.photo : '');
}
function ownerBody_(overdue, toCheck) {
let body = '';
Object.keys(overdue).forEach(function (who) {
body += who + '\n' + overdue[who].map(line_).join('\n') + '\n\n';
});
if (toCheck.length) body += 'Marked Fixed, waiting for your check\n' + toCheck.map(line_).join('\n') + '\n';
return body;
}
function partyBody_(who, items) {
return 'Hi ' + who + ',\n\nThese punch list items are past their due date:\n\n' +
items.map(line_).join('\n') + '\n\nReply when they are done so we can check them.\n';
}
function contactEmails_() {
const sheet = SpreadsheetApp.getActive().getSheetByName(CONFIG.CONTACTS_SHEET);
const map = {};
if (!sheet) return map;
sheet.getDataRange().getValues().slice(1).forEach(function (r) {
if (r[0] && r[1]) map[String(r[0]).trim().toLowerCase()] = String(r[1]).trim();
});
return map;
}
function countItems_(overdue) {
return Object.keys(overdue).reduce(function (n, k) { return n + overdue[k].length; }, 0);
}
function startOfDay_(d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate()); }
function daysBetween_(a, b) { return Math.round((b - a) / 86400000); }
function setupPunchTrigger() {
ScriptApp.getProjectTriggers().forEach(function (t) {
if (t.getHandlerFunction() === 'sendPunchReminders') ScriptApp.deleteTrigger(t);
});
ScriptApp.newTrigger('sendPunchReminders').timeBased().everyDays(1).atHour(7).create();
}
Run setupPunchTrigger once. It schedules sendPunchReminders for the 7 am hour each day. Google runs a time trigger at some point inside the hour you pick, not at the exact minute. The script skips weekends.
What should you check before you trust it?
I tested the script with made-up items in a mock harness (14 of 14 checks passed) and a syntax check. I did not run it in a live Google account. Test it on a copy of your sheet with your own email:
- Add four items: one overdue and
Open, one not yet due, oneFixed, oneVerified. - Run
sendPunchRemindersby hand. - Check that the email lists the overdue item and the fixed item, and not the other two.
- Check that
Last remindedis filled for the two that were listed. - Run it again and check that no second email comes.
- To test the parties setting, set it to
true, add a contact with your second address and clearLast reminded.
Step 3 matters most. I could not confirm in my environment how Sheets passes a date cell to a script when the cell was typed as text. If an item you expect is missing, check that its due date is a real date, not text.
What else should you know?
- Dates must be dates. A due date typed as plain text is skipped.
- Spelling matters. The
Responsiblename must matchContactsexactly to find an email. - Weekends. The script sends nothing on a Saturday or Sunday.
- Email limits. Scripts have a daily email quota. See Google's quotas page for the current numbers.
- No photos in the email. It sends the link.
- A Fixed status is a claim. You still have to look at the work.
Where does an LLM fit in this?
Not in the reminders. Due dates and statuses are rules, and rules are easier to trust than a model. An LLM can help in one place: turning a messy walkthrough note or a voice memo into a list of items with a location and a one-line description. A person should check that list before it goes into the sheet, because a wrong location or a missed item costs more than a slow entry. My stance: AI writes the code, plain automation runs it, and an LLM shows up only where the input is messy and a person checks the result. For where that line goes wrong, read why AI agents fail.
Where this has limits
- Tracking, not advice. This post tracks a list. It does not say what your contract, state or insurer requires. Check with an attorney, insurer or safety professional.
- The federal clause is an example. GSAM 552.211-70 is one clause for one agency's contracts, read on October 30, 2026. It can change.
- Not run in a live account. Mock-tested only. Test on a copy.
- It sends email as you. Read the permission screen.
- A sheet is not a signed acceptance. Closeout paperwork comes from your contract.
- Project software may fit better. When many people edit at once, or an owner requires a specific system, use what your contract and your team need.
- Template pages change. My look at the search results is from October 30, 2026.
If closeout is dragging, see our AI-powered operations work or tell us about it on /letstalk. Bring the list you keep today and the step that gets dropped. Do not send contracts, drawings, passwords or login details.
Frequently asked questions
What should a punch list include?
An item number, location, a one-line description, who is responsible, a photo, a due date and a status. The script above also tracks when it last reminded you.
Can Google Sheets send punch list reminders?
Not by itself. A Google Apps Script can email you a digest of overdue items each weekday, which is what the script above does. Test it on a copy first.
Does the sheet replace the contract's closeout process?
No. Your contract sets what counts as complete and how items are accepted. The sheet tracks the work.
Who gets the reminder emails?
By default, only you. A setting sends each responsible party their own list if they have an address in the Contacts tab.
Can I attach photos?
Add a link to a photo in Drive. The script puts the link in the email. Check who can open the folder.
Do I need to know how to code?
No. You paste the script, change one email address and approve the permissions. An AI tool can help you edit the wording. Test it on a copy first.
Read next: The closeout documents tracker starts where the punch list ends: manuals, warranties and as-builts in one place.