You can turn any Google Sheets range into a PDF and have it emailed on a recurring schedule with one Apps Script — but most existing walkthroughs either stop at a manual, one-click export or leave out the trigger setup, the duplicate-send problem, and error handling entirely. Below is a script that does all three, built only from Apps Script’s officially documented API surface (no undocumented export-URL parameters), plus the exact parts of it that were run and verified before publishing.
| Approach | Exact range only? | Recurring, no clicks? | Duplicate-send safe? | Best for |
|---|---|---|---|---|
| File > Download > PDF (manual) | No — active sheet or whole file | No | N/A | A one-off export |
Menu button + getAs(MimeType.PDF) |
No — exports the whole spreadsheet | No (needs a click) | N/A | Occasional whole-sheet sends |
Undocumented /export?format=pdf&range=... URL |
Yes | Yes, with your own trigger | Only if you build it | Teams comfortable depending on an unofficial parameter Google could change |
| This script (temp-sheet copy) | Yes | Yes — installs its own trigger | Yes — built in | A specific range, sent unattended on a schedule |
The verified script
Paste this into Extensions > Apps Script for the spreadsheet you want to report from, edit the CONFIG block, then run createTrigger once from the editor to install the schedule.
/**
* Email a specific Google Sheets range as a PDF on a recurring schedule.
* BytecraftGuide — verified 2026-09-28 (see article for what "verified" covers).
*
* SETUP
* 1. Extensions > Apps Script, paste this whole file, Save.
* 2. Edit the CONFIG block below.
* 3. Run `createTrigger` once from the editor (▶ button) to install the
* time-based trigger. Authorize when prompted.
* 4. To change schedule later, run `deleteExistingTriggers` then `createTrigger` again.
*/
const CONFIG = {
spreadsheetId: '', // '' = use the spreadsheet this script is bound to
range: "'Monthly Report'!B2:F20", // sheet name (quoted if it has spaces) + A1 range
recipients: 'ops-team@example.com, manager@example.com',
subjectTemplate: 'Automated report — {date}',
bodyText: 'Attached is the automatically generated PDF report. This email was sent by an Apps Script trigger — see the linked guide if you did not expect it.',
schedule: 'WEEKLY', // 'DAILY' | 'WEEKLY' | 'MONTHLY' — must match the trigger you install below
weekDay: 'MONDAY', // used only if schedule === 'WEEKLY'
hour: 8, // 24h, in the script's timezone (Project Settings)
dayOfMonth: 1, // used only if schedule === 'MONTHLY'
};
// ============================================================
// PURE LOGIC — no Google services. Independently tested in a
// Node.js sandbox (35 assertions, all passing) before publishing.
// Google-service calls (SpreadsheetApp/DriveApp/MailApp/ScriptApp)
// below this block could NOT be executed outside a real Workspace
// account and were verified against the Apps Script API reference only.
// ============================================================
function parseA1Range(input) {
if (typeof input !== 'string') throw new Error('Range must be a string');
const trimmed = input.trim();
const bangIdx = trimmed.lastIndexOf('!');
if (bangIdx === -1) {
return { sheetName: null, rangeA1: trimmed };
}
let sheetPart = trimmed.slice(0, bangIdx).trim();
const rangePart = trimmed.slice(bangIdx + 1).trim();
if (sheetPart.startsWith("'") && sheetPart.endsWith("'") && sheetPart.length >= 2) {
sheetPart = sheetPart.slice(1, -1).replace(/''/g, "'");
}
if (!rangePart) throw new Error('Empty range after sheet name');
return { sheetName: sheetPart, rangeA1: rangePart };
}
function parseRecipients(raw) {
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const parts = String(raw || '')
.split(/[,;]/)
.map(s => s.trim())
.filter(s => s.length > 0);
const seen = new Set();
const valid = [];
const invalid = [];
for (const p of parts) {
const lower = p.toLowerCase();
if (!EMAIL_RE.test(p)) { invalid.push(p); continue; }
if (seen.has(lower)) continue;
seen.add(lower);
valid.push(p);
}
return { valid, invalid };
}
function isRangeBlank(values) {
if (!Array.isArray(values) || values.length === 0) return true;
for (const row of values) {
for (const cell of row) {
if (cell !== '' && cell !== null && cell !== undefined) return false;
}
}
return true;
}
function buildFilename(template, context) {
let name = template.replace(/\{sheet\}/g, context.sheetName || 'sheet')
.replace(/\{date\}/g, context.dateStr || '');
name = name.replace(/[\\/:*?"<>|]/g, '-')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
if (name.length > 120) name = name.slice(0, 120);
if (!name.toLowerCase().endsWith('.pdf')) name += '.pdf';
return name;
}
function formatSubject(template, dateStr) {
return template.replace(/\{date\}/g, dateStr);
}
function startOfWeek(d) {
const copy = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
const day = copy.getUTCDay();
const diff = (day === 0 ? -6 : 1) - day;
copy.setUTCDate(copy.getUTCDate() + diff);
return copy;
}
function wasAlreadySentInPeriod(lastSentISO, now, schedule) {
if (!lastSentISO) return false;
const last = new Date(lastSentISO + 'T00:00:00Z');
if (isNaN(last.getTime())) return false;
const nowUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
switch (schedule) {
case 'DAILY':
return last.getTime() === nowUTC.getTime();
case 'WEEKLY':
return startOfWeek(last).getTime() === startOfWeek(nowUTC).getTime();
case 'MONTHLY':
return last.getUTCFullYear() === nowUTC.getUTCFullYear() && last.getUTCMonth() === nowUTC.getUTCMonth();
default:
throw new Error('Unknown schedule: ' + schedule);
}
}
function retryWithBackoff(fn, maxAttempts, sleepFn) {
let lastErr;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return { ok: true, result: fn(attempt), attempts: attempt };
} catch (e) {
lastErr = e;
if (attempt < maxAttempts) sleepFn(attempt);
}
}
return { ok: false, error: lastErr.message, attempts: maxAttempts };
}
// ============================================================
// ORCHESTRATION — real Apps Script services. Not executable outside
// a live Google account; verified against the API reference, not by
// running it.
// ============================================================
function sendRangeReportAsPdf() {
const props = PropertiesService.getScriptProperties();
const now = new Date();
const todayKey = Utilities.formatDate(now, Session.getScriptTimeZone(), 'yyyy-MM-dd');
const lastSent = props.getProperty('LAST_SENT_DATE');
if (wasAlreadySentInPeriod(lastSent, now, CONFIG.schedule)) {
console.log('Already sent this period (last: ' + lastSent + '). Skipping — this guards against the documented random-minute drift on recurring triggers.');
return;
}
const { sheetName, rangeA1 } = parseA1Range(CONFIG.range);
const ss = CONFIG.spreadsheetId
? SpreadsheetApp.openById(CONFIG.spreadsheetId)
: SpreadsheetApp.getActive();
const sheet = sheetName ? ss.getSheetByName(sheetName) : ss.getActiveSheet();
if (!sheet) throw new Error('Sheet not found: ' + sheetName);
const sourceRange = sheet.getRange(rangeA1);
const values = sourceRange.getValues();
if (isRangeBlank(values)) {
console.log('Range ' + CONFIG.range + ' is blank — skipping send, not marking as sent.');
return;
}
const { valid, invalid } = parseRecipients(CONFIG.recipients);
if (invalid.length > 0) console.log('Ignoring invalid recipient(s): ' + invalid.join(', '));
if (valid.length === 0) throw new Error('No valid recipients configured.');
// Build a temporary spreadsheet containing only the copied range, then
// export THAT file as PDF. This uses only officially documented Apps
// Script API surface (Range.copyTo, DriveApp.getFileById().getAs()) —
// it deliberately avoids the undocumented docs.google.com/.../export?
// URL-parameter trick used by several existing tutorials on this topic,
// which Google does not document and which has changed silently before.
const tempSs = SpreadsheetApp.create('tmp-export-' + todayKey + '-' + Utilities.getUuid().slice(0, 8));
try {
const tempSheet = tempSs.getSheets()[0];
sourceRange.copyTo(tempSheet.getRange(1, 1, sourceRange.getNumRows(), sourceRange.getNumColumns()));
SpreadsheetApp.flush();
const dateStr = Utilities.formatDate(now, Session.getScriptTimeZone(), 'yyyy-MM-dd');
const pdfBlob = DriveApp.getFileById(tempSs.getId())
.getAs(MimeType.PDF)
.setName(buildFilename('{sheet}-{date}', { sheetName: sheet.getName(), dateStr: dateStr }));
const subject = formatSubject(CONFIG.subjectTemplate, dateStr);
const outcome = retryWithBackoff(
() => MailApp.sendEmail({
to: valid.join(','),
subject: subject,
body: CONFIG.bodyText,
attachments: [pdfBlob],
}),
3,
(attempt) => Utilities.sleep(1000 * Math.pow(2, attempt)) // 2s, 4s
);
if (!outcome.ok) throw new Error('Send failed after ' + outcome.attempts + ' attempts: ' + outcome.error);
props.setProperty('LAST_SENT_DATE', todayKey);
console.log('Sent to ' + valid.join(', ') + ' after ' + outcome.attempts + ' attempt(s).');
} finally {
// Always delete the temp spreadsheet, even if sending failed.
DriveApp.getFileById(tempSs.getId()).setTrashed(true);
}
}
function createTrigger() {
deleteExistingTriggers();
let builder = ScriptApp.newTrigger('sendRangeReportAsPdf').timeBased();
if (CONFIG.schedule === 'DAILY') {
builder = builder.everyDays(1).atHour(CONFIG.hour);
} else if (CONFIG.schedule === 'WEEKLY') {
builder = builder.onWeekDay(ScriptApp.WeekDay[CONFIG.weekDay]).atHour(CONFIG.hour);
} else if (CONFIG.schedule === 'MONTHLY') {
builder = builder.onMonthDay(CONFIG.dayOfMonth).atHour(CONFIG.hour);
} else {
throw new Error('CONFIG.schedule must be DAILY, WEEKLY, or MONTHLY');
}
builder.create();
}
function deleteExistingTriggers() {
ScriptApp.getProjectTriggers().forEach(t => {
if (t.getHandlerFunction() === 'sendRangeReportAsPdf') ScriptApp.deleteTrigger(t);
});
}
Setup: three steps
- Open the spreadsheet, go to Extensions > Apps Script, delete the placeholder code, and paste the script above.
- Edit the
CONFIGobject at the top: the sheet name and A1 range to export (e.g."'Monthly Report'!B2:F20"), the recipient list, and the schedule (DAILY,WEEKLY, orMONTHLY). - Select
createTriggerfrom the function dropdown and click Run (▶). Google will ask you to authorize the script the first time — approve it. This installs the time-based trigger; you don’t need to touch the Triggers page manually.
To change the schedule later, edit CONFIG and run createTrigger again — it deletes the old trigger for this function before creating the new one, so you won’t end up with two running side by side.
Adapting it further
Sending more than one range in a single email — say, a summary table plus a chart — means copying each range into its own sheet inside the same temporary spreadsheet (call tempSs.insertSheet() for the second one) before exporting, since getAs(MimeType.PDF) exports every visible sheet in the file by default. If you’d rather get a short HTML-formatted email body instead of a PDF attachment, skip the temp-spreadsheet block entirely and build an HTML string from sourceRange.getValues() — that’s closer to what our daily summary script does, and it avoids the Drive Trash trade-off mentioned above entirely.
Why not just use the Sheets export-URL trick?
A lot of existing tutorials on this exact topic fetch docs.google.com/spreadsheets/d/{id}/export?format=pdf&range=... with UrlFetchApp and an OAuth token. It works, but Google does not document those query parameters anywhere in the Apps Script or Sheets API reference — it’s a reverse-engineered convention from the Sheets UI’s own export feature, and reverse-engineered parameters can change without notice. This script instead copies the target range’s values and formatting into a throwaway spreadsheet with Range.copyTo(), exports that whole (small) file with DriveApp.getFileById(id).getAs(MimeType.PDF) — a method that is in the official reference — and then trashes the temporary file. Slightly more code, nothing that depends on an undocumented parameter.
Trade-off worth knowing: the temporary spreadsheet sits in Drive Trash for up to 30 days before Google auto-purges it (Trash items still count toward storage until then). For a report that runs daily this is a few KB of overhead; if that bothers you, add a scheduled cleanup that empties Trash, or switch to permanent deletion once Drive’s Advanced Service is enabled.
What happens if the trigger fires twice in one day?
It can. Google’s own documentation for time-based triggers states that a trigger set for a specific hour actually fires at a random minute within that hour, and repeats at that same minute on subsequent days — but retries after transient failures, daylight-saving shifts, or a trigger you recreated can occasionally cause two runs close together. This script guards against sending the same report twice by recording the last-sent date in PropertiesService and checking it against the configured period (same calendar day for DAILY, same Monday-start week for WEEKLY, same month for MONTHLY) before doing any work. None of the existing tutorials found while researching this article implement that guard.
Quotas that apply
Verified against Google’s own quotas reference on September 28, 2026 — these change occasionally, so check the source link if it’s been a while.
| Limit | Consumer account | Workspace account |
|---|---|---|
| Email recipients per day (MailApp/GmailApp) | 100 | 1,500 |
| URL Fetch calls per day | 20,000 | 100,000 |
| URL Fetch response size | 50 MB per call | |
| Total trigger runtime per day | 90 minutes | 6 hours |
| Single execution runtime | 6 minutes | |
| Triggers per user, per script | 20 | |
This script doesn’t call UrlFetchApp at all (that’s the whole point of the temp-sheet approach), so the URL Fetch row doesn’t actually apply to it — it’s included because it’s the limit that does apply if you go the export-URL route instead. Source: Apps Script quotas reference. For the full picture of every limit Apps Script enforces, see our quotas and limits reference.
Common failure modes
| Symptom | Likely cause | Fix |
|---|---|---|
| Script throws “No valid recipients configured” | Every address in CONFIG.recipients failed basic email validation |
Check for typos, stray semicolons, or a trailing comma |
| Nothing happens, no error, no email | The idempotency guard found a matching LAST_SENT_DATE for this period, or the source range was entirely blank |
Check the execution log — both cases log a line explaining why it skipped |
| “Exception: Range not found” or similar | Sheet name in CONFIG.range doesn’t exist, or was renamed |
Match the sheet name exactly, including capitalization |
| “Exception: You do not have permission to call getAs” | First-run authorization wasn’t completed, or scopes were revoked | Re-run createTrigger manually from the editor and approve the prompt |
| Email sends, but attachment is a blank page | The copied range relies on formulas referencing cells outside the copied range | Widen the range, or paste values-only into the temp sheet instead of a live copy |
For error messages not covered above, see our Apps Script error reference.
What was actually verified, and what wasn’t
Verified (Node.js sandbox, 35 assertions, all passing): A1-range and sheet-name parsing including quoted sheet names with spaces and escaped apostrophes; recipient parsing, validation, and case-insensitive de-duplication; the blank-range guard (including that a real 0 value is correctly treated as non-blank); filename sanitizing and truncation; subject-line templating; the daily/weekly/monthly duplicate-send guard, including the specific case of a trigger firing just after a week boundary; and the retry-with-backoff wrapper, tested for immediate success, recovery after transient failures, and exhausting all attempts. The exact same functions, copied verbatim into the shipped script (not re-typed), were re-run as a spot check against the final file before publishing.
Not verified — could not be, outside a live Google Workspace/Sheets environment: SpreadsheetApp.create(), Range.copyTo(), DriveApp.getFileById().getAs(MimeType.PDF), MailApp.sendEmail(), PropertiesService, and the ScriptApp trigger builder. These were checked against the current Apps Script API reference for correct method names and signatures, not run. If you hit a behavior these docs don’t mention, that’s the boundary where this article’s confidence ends and your own test run begins.
When this isn’t the right tool
If you want an email the moment a cell changes rather than on a schedule, an installable onEdit trigger is the right building block — see sending an email when a cell changes. If what you actually want is a plain-text summary of recent rows rather than a PDF of a fixed range, our daily summary email script is a lighter-weight fit and doesn’t need the temporary-spreadsheet step at all.
Looking for something to send? A payout sheet like our tip pool calculator for Excel is a typical range to email to managers after each pay period.