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:

StudentClassTermMathReadingScienceAverageLetter gradeCommentsReport PDFGenerated on
Ana Diaz5BTerm 19288.59591.8AGreat term…
Ben Ito5BTerm 1718075.5C

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):

MinLetter
0F
60D
70C
80B
90A

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

Tips and caveats

Want reports without maintaining a script? SheetWeave (disclosure: we make it) is a Google Sheets add-on that fills a Google Docs template from each row and saves a Doc, a PDF, or both in a Drive folder you choose, writing the links back to the row. It can run on selected rows, all rows, or only new ones, and file names can be built from your columns (for example term and student). Keep the average and letter grade as formula columns like the ones above, and it merges them like any other column. Sheet data stays inside your Google account and it uses narrow scopes. 14-day free trial, no credit card required.
See SheetWeave

Related Google Sheets guides

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.