How to Make Student Progress Reports from Google Sheets (One PDF per Student, 2026)
If your grades already live in a Google Sheet, writing progress reports usually means copying each student's scores into a document, one at a time, and hoping nothing lands on the wrong child's report. This guide turns that into one menu click: a Google Docs report template, a grades tab with an average and letter grade worked out by formulas, and a free Apps Script that saves one PDF per student and writes the link back to the row.
It suits teachers' interim progress reports, tutoring centers, homeschool records, after-school programs, music lessons and sports coaching. If your school issues official report cards from a student information system, use that for the official record; this is for the reports you make yourself. For the general template-and-merge method, see a PDF or Doc for each row.
Watch the 2-minute walkthrough on YouTube. Short on time? Watch the 50-second version.
Step 1: a grades tab, one row per student
Make one tab per reporting period (for example “Term 1”), so last term's grades and links stay untouched when you start the next one. Row 1 is the header row. The script finds columns by name, so the order doesn't matter and you can add any columns you like:
| Student | Class | Term | Math | Reading | Science | Average | Letter grade | Comments | Report PDF | Generated on |
|---|---|---|---|---|---|---|---|---|---|---|
| Ana Diaz | 5B | Term 1 | 92 | 88.5 | 95 | 91.8 | A | Great term… | ||
| Ben Ito | 5B | Term 1 | 71 | 80 | 75.5 | C |
Only three columns are required: Student, plus Report PDF and Generated on, which you leave empty for the script to fill. Everything else is up to you: subjects, attendance, effort ratings, a teacher's comment, a parent or guardian name.
Step 2: average and letter grade with formulas
Keep the math in the sheet, where you can see and check it, rather than inside the script. With subject scores in D:F, the average (ignoring blank scores, rounded to one decimal) in G2 is:
=IF(COUNT(D2:F2)=0,"",ROUND(AVERAGE(D2:F2),1))
For letter grades, add a small Scale tab with the lowest score for each grade in column A, sorted from low to high, and the letter in column B (A1 and B1 are headers):
| Min | Letter |
|---|---|
| 0 | F |
| 60 | D |
| 70 | C |
| 80 | B |
| 90 | A |
That scale is an example only; use your school's or program's own. Then in H2:
=IF(G2="","",VLOOKUP(G2,Scale!$A$2:$B,2,TRUE))
The TRUE makes VLOOKUP pick the highest Min that is at or below the average, so 89.9 gets a B and 90 gets an A. If some items should count more than others, use a weighted formula such as SUMPRODUCT instead; the script prints whatever the Average column shows.
Step 3: the report template in Google Docs
Create a Doc with your logo, school or program name and layout. Wherever a value should go, type the column header in double braces. Any header works, plus {{Today}} for today's date. Type each placeholder in one go; if you edit the middle of one later, retype the whole thing.
{{Term}} Progress Report
Student: {{Student}} Class: {{Class}} Date: {{Today}}
Math {{Math}}
Reading {{Reading}}
Science {{Science}}
Average {{Average}} ({{Letter grade}})
Teacher's comments:
{{Comments}}
A table in the Doc works well for the subject lines; placeholders inside table cells are filled too. Placeholders in the page header or footer are not, so keep them in the body. Copy the template ID (between /d/ and /edit in its URL) and the ID of a Drive folder for the finished PDFs (after /folders/).
Step 4: the script
In the sheet, open Extensions › Apps Script, paste the script below and fill in the two IDs. In the Apps Script editor, open Project Settings and make the time zone match the spreadsheet's (File › Settings in the sheet), so {{Today}} shows the right date. Save, reload the sheet, open the term's tab, then use Reports › Make reports for rows without a PDF and approve the permissions the first time.
/**
* Student progress reports from Google Sheets: one PDF per student row.
*
* Works on the tab you have open (for example "Term 1"). Row 1 is the header row.
* Required columns: Student | Report PDF | Generated on (all other columns are optional)
* Every other header can be used in the Google Docs template as {{Header name}},
* for example {{Student}}, {{Math}}, {{Average}}, {{Letter grade}}, {{Comments}}.
* Values are merged exactly as the sheet displays them (number formats, dates, %).
* Blank cells become BLANK_TEXT. {{Today}} is today's date.
*
* "Make reports for rows without a PDF" skips rows that already have a link.
* "Remake reports for selected rows" makes a fresh PDF for the selected rows
* (the old PDF is left in the folder; delete it yourself if you don't need it).
*/
const TEMPLATE_ID = 'PASTE_TEMPLATE_DOC_ID';
const FOLDER_ID = 'PASTE_OUTPUT_FOLDER_ID';
const NAME_PARTS = ['Term', 'Class', 'Student']; // file name, e.g. "Term 1 - 5B - Ana Diaz.pdf"
const BLANK_TEXT = '—';
const KEEP_DOCS = false; // true keeps the filled Google Doc next to each PDF
const MAX_PER_RUN = 40; // stays well inside Apps Script's 6-minute limit
function onOpen() {
SpreadsheetApp.getUi().createMenu('Reports')
.addItem('Make reports for rows without a PDF', 'makeNewReports')
.addItem('Remake reports for selected rows', 'remakeSelectedReports')
.addToUi();
}
function makeNewReports() { return makeReports_(null); }
function remakeSelectedReports() {
const sh = SpreadsheetApp.getActiveSheet();
const rows = {};
const list = sh.getActiveRangeList();
(list ? list.getRanges() : []).forEach((rg) => {
for (let r = rg.getRow(); r < rg.getRow() + rg.getNumRows(); r++) if (r > 1) rows[r] = true;
});
if (!Object.keys(rows).length) throw new Error('Select at least one student row (not the header) first.');
return makeReports_(rows);
}
function makeReports_(onlyRows) {
const lock = LockService.getDocumentLock();
if (!lock.tryLock(30000)) throw new Error('Another run is in progress. Try again in a minute.');
try {
const ss = SpreadsheetApp.getActive();
const sh = ss.getActiveSheet();
const tz = ss.getSpreadsheetTimeZone();
const shown = sh.getDataRange().getDisplayValues(); // what you see in the sheet
const head = shown[0].map((h) => String(h).trim());
const col = (name) => {
const i = head.indexOf(name);
if (i < 0) throw new Error('"' + sh.getName() + '" is missing the column "' + name + '"');
return i;
};
const cStudent = col('Student'), cPdf = col('Report PDF'), cWhen = col('Generated on');
const skip = [cPdf, cWhen];
const folder = DriveApp.getFolderById(FOLDER_ID);
const template = DriveApp.getFileById(TEMPLATE_ID);
const today = new Date();
const unknown = {};
let made = 0, left = 0;
for (let r = 1; r < shown.length; r++) {
const row = shown[r];
const student = String(row[cStudent]).trim();
if (!student) continue;
if (onlyRows ? !onlyRows[r + 1] : String(row[cPdf]).trim() !== '') continue;
if (made >= MAX_PER_RUN) { left++; continue; }
const fields = { 'Today': Utilities.formatDate(today, tz, 'MMMM d, yyyy') };
head.forEach((h, i) => {
if (!h || skip.indexOf(i) >= 0) return;
const v = String(row[i]).trim();
fields[h] = v === '' ? BLANK_TEXT : v;
});
const name = fileName_(NAME_PARTS.map((p) => fields[p]).filter((v) => v && v !== BLANK_TEXT)) || student;
const copy = template.makeCopy(name, folder);
const doc = DocumentApp.openById(copy.getId());
const body = doc.getBody();
Object.keys(fields).forEach((k) => fill_(body, k, fields[k]));
leftovers_(body.getText()).forEach((p) => { unknown[p] = true; });
doc.saveAndClose();
const pdf = folder.createFile(copy.getAs(MimeType.PDF).setName(name + '.pdf'));
if (!KEEP_DOCS) copy.setTrashed(true);
sh.getRange(r + 1, cPdf + 1).setValue(pdf.getUrl());
sh.getRange(r + 1, cWhen + 1).setValue(today);
made++;
}
const missing = Object.keys(unknown);
ss.toast('Made ' + made + ' report(s).' +
(left ? ' ' + left + ' left: run it again to finish.' : '') +
(missing.length ? ' No column for: ' + missing.map((m) => '{{' + m + '}}').join(', ') + '.' : ''), 'Reports', 10);
return { made: made, left: left, missing: missing };
} finally {
lock.releaseLock();
}
}
/** Replaces every {{key}} in the document body with value (taken literally, no regex). */
function fill_(body, key, value) {
const pattern = '\\{\\{' + key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\}\\}';
value = String(value).replace(/\{\{/g, '{ {'); // a value can't create a new placeholder
let found = body.findText(pattern);
while (found) {
const text = found.getElement().asText();
const start = found.getStartOffset();
text.deleteText(start, found.getEndOffsetInclusive());
if (value) text.insertText(start, value);
found = body.findText(pattern);
}
}
/** Placeholder names still left in the text after filling (typos or missing columns). */
function leftovers_(text) {
const out = [];
const re = /\{\{([^{}]+)\}\}/g;
let m;
while ((m = re.exec(text))) if (out.indexOf(m[1]) < 0) out.push(m[1]);
return out;
}
/** Joins name parts and removes characters that cause trouble in file names. */
function fileName_(parts) {
return parts.join(' - ').replace(/[\\/:*?"<>|#%]+/g, '-').replace(/\s+/g, ' ').trim().slice(0, 150);
}
What the script does, and why
- Values look the way they look in the sheet. It reads the displayed values, so a score formatted as
91.8or92%appears exactly like that, not as91.79999999, and dates keep your sheet's date format. To change how something prints, change its format in the sheet (Format › Number). - Blank cells print as a dash. A missing score or empty comment shows “—” instead of leaving a gap or a stray placeholder. Change
BLANK_TEXTto''if you'd rather print nothing. - Typos are caught. If the template has a placeholder with no matching column, such as
{{Grade}}when the column is “Letter grade”, the message at the end of the run lists it, so you can fix the template before sending anything. - Nothing is made twice. Rows that already have a Report PDF link are skipped. After you correct a score, select that student's row and use Reports › Remake reports for selected rows; it makes a fresh PDF and replaces the link. The old PDF stays in the folder, so delete it if you don't want two versions around.
- Values are pasted literally. A comment containing
$, a backslash or braces is copied as typed, and one student's text can't be mistaken for another placeholder. - Safe file names. Files are named from Term, Class and Student (for example
Term 1 - 5B - Ana Diaz.pdf); characters that break file names are replaced. ChangeNAME_PARTSto use other columns. - Big classes. It makes at most 40 reports per run to stay inside Apps Script's six-minute limit and tells you how many are left; run it again to finish.
Tips and caveats
- Check before you share. Open two or three PDFs, including a student with a missing score, before sending any. A wrong formula affects every report at once.
- Sending to parents. The script saves PDFs and links only. To email each one, add a
MailApp.sendEmailcall with the PDF attached inside the loop (step 4 of the invoices and certificates guide shows the pattern), using a guardian email column, and test it on yourself first. Gmail has daily sending quotas, and your school may have rules about how reports reach families. - Student privacy. Grades and comments are student records. In the US, schools handle them under FERPA and their own policies. Keep the sheet and the PDF folder shared only with staff who need them, never make the folder public, and follow your school's rules on where student data may be stored.
- Permissions. Because the script uses DriveApp and DocumentApp, Google will ask to let it see, edit, create and delete your Drive files and Docs. That's normal for a script you wrote yourself, but it's broad access.
- Certificates too. For end-of-course certificates rather than reports, see numbered completion certificates or a certificate from a Google Form response.
See SheetWeave
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
- 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 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 and Google Drive are trademarks of Google LLC. Features change, so verify in your own account. Questions or corrections: hello@loomwise.app.