Generate Google Docs from a Sheet row
Proposals, letters, or briefs are built by copying data out of a spreadsheet into a template by hand, one row at a time.
Proposals, letters, or briefs can be generated as Google Docs from a Google Sheet row the moment it is marked ready, with the template populated from the row's data and the document shared with its owner. Evenops builds this in Apps Script so every document follows the same template and the sheet records what has been generated.
How does it run?
3 stepsStep 01Trigger
Row marked ready in Sheets
Step 02Populate
Template populated with that row's data
Step 03Create
Google Doc created and shared with the owner
What do you get?
3 outcomes- ✓Documents generate from the sheet, not from a copy-paste pass
- ✓Every doc follows the same template
- ✓The sheet stays the record of what's been generated
Which tools does it use?
Time saved
Replaces manual copy-pasting into document templates.What does the script do?
A time-driven Apps Script that looks at a Sheet every ten minutes for rows whose Status is Ready. For each one it copies a Google Docs template into a Drive folder, replaces every {{Heading}} in the copy with that row's value, shares the document with the row's owner, and writes the link and the time back to the row. The Status becomes Generated, or Generated with gaps if the template had a placeholder with no matching column, or Failed with the reason. Each row needs a unique Ref, which the script uses to name the document, so a row can never produce two files. Fill the template, then people edit and send the document themselves.
What do you need first?
- A Google Doc to use as the template, with {{Heading}} written wherever a value should go. The text between the brackets must match a column heading in the Sheet exactly.
- A Google Sheet with a heading row that includes Ref (unique on every row) and Status, and a Drive folder for the finished documents.
- The IDs of the template, the Sheet and the folder.
- About 30 minutes. No paid add-on.
How do you set it up?
8 stepsMake the template
Write the document once, in the style you want, and put placeholders such as {{Client}} and {{Amount}} where the values go. Placeholders in headers and footers are not filled, so keep them in the body.
Prepare the sheet
Add a Ref column with a different value on every row, a Status column, and one column per placeholder. An Owner email column is optional.
Create the script
Open script.google.com in the account that owns the template and folder, create a project and paste the code below into Code.gs.
Fill in CONFIG
Set SPREADSHEET_ID, TEMPLATE_DOC_ID, OUTPUT_FOLDER_ID and ALERT_TO, and check the heading names (REF_HEADER, NAME_HEADER, OWNER_HEADER, STATUS_HEADER) match your sheet.
Run it once by hand
Mark one test row Ready, with your own address as owner, pick generateDocs and press Run. Authorise Sheets, Docs, Drive and email access. Check the document in the folder, and the link, time and message written to the row.
Install the trigger
Pick installTrigger and press Run once. It schedules generateDocs every ten minutes and replaces any earlier copy.
Agree the rules with the team
Type Ready to ask for a document. Never reuse a Ref. To make a document again, set the Status back to Ready: the script makes a fresh copy of the template named with a version, such as Ref - Client (v2), and the earlier document is left exactly as it was.
Turn on failure emails
In Triggers, open the trigger and set Failure notification settings to notify you immediately.
The script
// Makes a Google Doc from a template for every Sheet row marked Ready, and writes the link back to the row.
// Paste into Code.gs in a new project at script.google.com, edit CONFIG, run installTrigger() once.
// In the template, write {{Heading}} wherever a value should go, using the Sheet's column headings.
const CONFIG = {
SPREADSHEET_ID: 'PASTE_THE_SPREADSHEET_ID_HERE',
SHEET_NAME: 'Documents',
TEMPLATE_DOC_ID: 'PASTE_THE_TEMPLATE_DOC_ID_HERE',
OUTPUT_FOLDER_ID: 'PASTE_THE_DRIVE_FOLDER_ID_HERE',
REF_HEADER: 'Ref', // a value that is different on every row, such as a proposal number
NAME_HEADER: 'Client', // used with the Ref in the document's file name
OWNER_HEADER: 'Owner email', // optional: this person is given edit access to their document
STATUS_HEADER: 'Status', // type Ready here to ask for a document
MAX_PER_RUN: 20,
// Stop well before the 6 minute limit. On a gmail.com account the whole day's
// trigger time is 90 minutes, so lower this (for example 60 * 1000) there.
TIME_BUDGET_MS: 4 * 60 * 1000,
ALERT_TO: 'ops@example.com',
ALERT_EVERY_MS: 6 * 60 * 60 * 1000,
};
const SCRIPT_HEADERS = ['Doc link', 'Generated on', 'Message']; // added to the right of your columns
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function generateDocs() {
const lock = LockService.getScriptLock();
if (!lock.tryLock(30 * 1000)) return;
const started = Date.now();
const problems = [];
let sheet = null;
let cols = null;
let out = null;
try {
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
sheet = ss.getSheetByName(CONFIG.SHEET_NAME);
if (!sheet) throw new Error('There is no tab called ' + CONFIG.SHEET_NAME);
// One read of the tab, as text exactly as it looks on screen.
const grid = sheet.getDataRange().getDisplayValues();
const headers = grid[0].slice();
cols = columns_(sheet, headers);
const rows = grid.slice(1);
out = rows.map(function (r) {
return { status: cell_(r, cols.status), link: cell_(r, cols.link), on: cell_(r, cols.on), message: cell_(r, cols.message) };
});
// Count each Ref once, so two rows can never share (and overwrite) one document.
const refCount = {};
rows.forEach(function (r) { const ref = cell_(r, cols.ref).trim(); if (ref) refCount[ref] = (refCount[ref] || 0) + 1; });
const folder = DriveApp.getFolderById(CONFIG.OUTPUT_FOLDER_ID);
const template = DriveApp.getFileById(CONFIG.TEMPLATE_DOC_ID);
let made = 0;
for (let i = 0; i < rows.length; i++) {
const o = out[i];
if (o.status.trim().toLowerCase() !== 'ready') continue;
if (made >= CONFIG.MAX_PER_RUN || Date.now() - started > CONFIG.TIME_BUDGET_MS) break; // the next run carries on
const ref = cell_(rows[i], cols.ref).trim();
if (!ref || refCount[ref] > 1) {
o.touched = true;
o.status = 'Failed';
o.message = ref ? 'Ref is used on more than one row' : 'Ref is empty';
problems.push('Row ' + (i + 2) + ': ' + o.message);
continue;
}
try {
const base = ref + (cols.name >= 0 && rows[i][cols.name] ? ' - ' + rows[i][cols.name] : '');
const notes = [];
let name = base;
let file;
if (o.link === '') {
// First document for this row. If an earlier run made it but stopped before saving the link, use that one.
const existing = folder.getFilesByName(name);
file = existing.hasNext() ? existing.next() : template.makeCopy(name, folder);
} else {
// The row already has a document and has been set to Ready again: a fresh copy of the template,
// named "... (v2)", "(v3)" and so on. The earlier documents are never changed or deleted.
let n = 2;
while (folder.getFilesByName(base + ' (v' + n + ')').hasNext()) n++;
name = base + ' (v' + n + ')';
file = template.makeCopy(name, folder);
notes.push('New version made, the earlier document is unchanged');
}
const body = DocumentApp.openById(file.getId()).getBody();
headers.forEach(function (h, c) {
if (h && SCRIPT_HEADERS.indexOf(h) === -1) fill_(body, '{{' + h + '}}', rows[i][c]);
});
const left = body.getText().match(/\{\{[^{}]+\}\}/g);
if (left) notes.push('Not filled: ' + left.filter(function (t, k) { return left.indexOf(t) === k; }).join(' '));
const owner = cols.owner >= 0 ? String(rows[i][cols.owner]).trim() : '';
const trouble = [];
if (owner) {
if (!EMAIL_PATTERN.test(owner)) {
trouble.push('Owner email not valid, document not shared');
} else {
try {
file.addEditor(owner);
} catch (err) {
trouble.push('Could not share with ' + owner + ': ' + err.message); // the document is still made and linked
}
}
}
o.touched = true;
o.status = left ? 'Generated with gaps' : 'Generated';
o.link = file.getUrl();
o.on = Utilities.formatDate(new Date(), ss.getSpreadsheetTimeZone(), 'yyyy-MM-dd HH:mm');
o.message = notes.concat(trouble).join('. ');
if (left || trouble.length) problems.push('Row ' + (i + 2) + ': ' + notes.concat(trouble).join('. '));
made++;
} catch (err) {
o.touched = true;
o.status = 'Failed';
o.message = err.message;
problems.push('Row ' + (i + 2) + ': ' + err.message);
}
}
} catch (err) {
problems.push('Run failed: ' + err.message);
} finally {
// Record what was done even if the loop stopped early, so nothing is repeated.
if (sheet && cols && out && out.length) {
try {
writeBack_(sheet, cols, out);
} catch (err) {
// Report it and carry on to the alert. Documents made this run are named by Ref, so a first-time
// document is reused on the next run; a regenerated one would get another version number.
problems.push('Could not save results to the sheet: ' + err.message);
}
}
lock.releaseLock();
}
alertOnce_(problems);
}
// Replaces every copy of a placeholder with the value, without treating the value as a pattern.
function fill_(body, token, value) {
const pattern = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const text = String(value).replace(/\{\{/g, '{ {'); // a value must never contain a placeholder of its own
for (let guard = 0; guard < 200; guard++) {
const found = body.findText(pattern);
if (!found) return;
const element = found.getElement().asText();
const start = found.isPartial() ? found.getStartOffset() : 0;
const end = found.isPartial() ? found.getEndOffsetInclusive() : element.getText().length - 1;
// Insert first, then remove the placeholder, so the paragraph is never left with no text.
if (text) element.insertText(start, text);
element.deleteText(start + text.length, end + text.length);
}
}
// Finds your columns by heading, and adds the three the script owns if they are missing.
function columns_(sheet, headers) {
const find = function (name) { return headers.indexOf(name); };
const cols = { ref: find(CONFIG.REF_HEADER), name: find(CONFIG.NAME_HEADER), owner: find(CONFIG.OWNER_HEADER), status: find(CONFIG.STATUS_HEADER) };
if (cols.ref < 0) throw new Error('The sheet has no column headed ' + CONFIG.REF_HEADER);
if (cols.status < 0) throw new Error('The sheet has no column headed ' + CONFIG.STATUS_HEADER);
const added = [];
SCRIPT_HEADERS.forEach(function (name) {
if (find(name) < 0) { headers.push(name); added.push(name); }
});
if (added.length) sheet.getRange(1, headers.length - added.length + 1, 1, added.length).setValues([added]);
cols.link = find('Doc link');
cols.on = find('Generated on');
cols.message = find('Message');
return cols;
}
function cell_(row, index) { return index >= 0 && index < row.length ? String(row[index]) : ''; }
function writeBack_(sheet, cols, out) {
const n = out.length;
const put = function (col, key) {
sheet.getRange(2, col + 1, n, 1).setValues(out.map(function (o) { return [safe_(o[key])]; }));
};
// People type Ready into the Status column while the script runs, so read it again just
// before writing and change only the rows this run dealt with.
const fresh = sheet.getRange(2, cols.status + 1, n, 1).getDisplayValues();
out.forEach(function (o, i) { if (!o.touched) o.status = fresh[i][0]; });
put(cols.status, 'status');
put(cols.link, 'link');
put(cols.on, 'on');
put(cols.message, 'message');
}
// Stops error text, which can include sheet content, running as a formula.
function safe_(value) {
const s = String(value);
return /^[=+@\t\r-]/.test(s) ? "'" + s : s;
}
// Keeps the held-back problems in a script property. Oldest entries are dropped first if the text
// would get long (a property value is limited to about 9 KB), and a failure to store never fails the run.
function saveQueue_(props, queue) {
try {
while (queue.length > 1 && JSON.stringify(queue).length > 6000) queue.shift();
props.setProperty('pendingProblems', JSON.stringify(queue));
} catch (e) { /* the problems are lost only if the store itself is down */ }
}
// Emails the problems to ALERT_TO, at most once every ALERT_EVERY_MS. Problems found while an email is
// being held back are kept in a script property and go out with the next one, so each is reported.
function alertOnce_(problems, force) {
const props = PropertiesService.getScriptProperties();
let queue = [];
try { queue = JSON.parse(props.getProperty('pendingProblems') || '[]'); } catch (e) { queue = []; }
(problems || []).forEach(function (p) {
const text = String(p).slice(0, 160);
if (queue.indexOf(text) === -1) queue.push(text);
});
queue = queue.slice(-30);
if (!queue.length) return;
const last = Number(props.getProperty('lastAlertAt') || 0);
if (!force && Date.now() - last < CONFIG.ALERT_EVERY_MS) {
saveQueue_(props, queue);
return;
}
try {
MailApp.sendEmail(CONFIG.ALERT_TO, 'Document generation needs attention (' + queue.length + ')', queue.join('\n'));
props.setProperty('lastAlertAt', String(Date.now()));
props.deleteProperty('pendingProblems');
} catch (err) {
saveQueue_(props, queue); // keep them for the next try
}
}
// Run once by hand. Safe to run again: it replaces the old trigger.
function installTrigger() {
ScriptApp.getProjectTriggers()
.filter(function (t) { return t.getHandlerFunction() === 'generateDocs'; })
.forEach(function (t) { ScriptApp.deleteTrigger(t); });
ScriptApp.newTrigger('generateDocs').timeBased().everyMinutes(10).create();
}
Every Apps Script method in this script was checked against Google's Apps Script reference (developers.google.com/apps-script/reference) on 11/10/2026. Limits come from Google's quota table, checked the same day. The find-and-edit calls on Docs text were checked against the Docs reference, including the rule that offsets are minus one when a match covers a whole element, the same day.
What should you know before relying on it?
9 caveats- Quotas
- Google's quota table lists a 6 minute limit per execution, 20 triggers per user per script, and a total trigger runtime of 90 minutes a day on a gmail.com account or 6 hours a day on Google Workspace. This trigger runs every 10 minutes. That is 144 runs a day, which on a gmail.com account works out to an average of about 38 seconds per run and on Workspace about 150. A run that finds nothing new is short, but a run that uses its full 4 minute budget costs 240 seconds, so about 22 of those use up a gmail.com day. Open Executions in the Apps Script editor to see how long your runs really take. Google's table also lists documents created per day (250 on gmail.com, 1,500 on Workspace); treat each generated document as counting against it. MAX_PER_RUN is 20, so a bulk change of status is spread over several runs.
- Values are placed as plain text
- The script finds each placeholder and inserts the value as literal text. It does not use the pattern-replace method, so a value containing a dollar sign, a backslash or brackets is written exactly as typed, and a value can never contain a placeholder of its own.
- Idempotency
- A document's name is its Ref plus the name column. For a row with no link yet, the script looks for a file with that name in the output folder and reuses it, so a run that stopped before saving the link cannot create a duplicate. For a row that already has a link and is set to Ready again, it deliberately makes a new version (v2, v3 and so on), because the placeholders in the first document have already been replaced and cannot be filled again. If the client's name was changed in the sheet, the new version carries the new name and the older documents keep theirs. A failed regeneration can leave one half-filled version in the folder, which you can delete. If the results cannot be saved back to the sheet after a regeneration, the row still looks unchanged, so the next run makes one more version; delete the extra one. Two rows with the same Ref are both marked Failed instead of sharing one document.
- Nothing is skipped silently
- A row with no Ref, a duplicate Ref, an error, a placeholder left unfilled, a share that failed or an owner address that is not valid is marked in the sheet and listed in the alert email. If the results cannot be saved back to the sheet at the end of a run, that is reported too. A document with gaps is still produced, with the unfilled placeholders named in the Message column.
- Formatting of inserted values
- Check a test document with a long value and a blank value, and look at how the inserted text is formatted, before the team relies on it. Placeholders in headers, footers, tables in other tabs or text boxes are not covered.
- People editing the Status column
- Because people type in Status while the script runs, it reads the column again just before saving and changes only the rows it handled, so a Ready typed during a run is not lost.
- Sharing
- The owner is given edit access to their document. Google's reference does not say whether this sends a notification, so test with your own address. If the share fails, the document is still made and its link is still written; the failure is noted in the Message column and reported to ALERT_TO.
- Who gets alerted
- Problem rows are emailed to ALERT_TO at most once every 6 hours, and every one is also marked in the sheet. Problems found while an alert is being held back are kept and sent with the next one, so each is reported once. Use a shared mailbox so the alert is not tied to one person.
- Ownership
- Documents are owned by the account that installed the trigger. If that person leaves and the account is suspended, the script stops and their documents go with the account. Install it from a dedicated shared Workspace user the business owns.
When should you call us?
Filling a template from a row is a good size for a script. Call us when the document needs tables that grow with the data, conditional sections, PDFs sent for signature, approvals before it is issued, or figures pulled from a CRM or accounting system. We build that as production code with checks and a record of what was generated and for whom.
Tell us what you needContinues · Want this wired into your stack?
Want this wired into your stack?
Tell us your tools and the task. We'll scope it and build it as production code you own.
We typically respond within 24 hours
