How to Generate Contracts and Agreements from Google Sheets with a Google Docs Template (2026)
Service agreements, freelancer and contractor agreements, vendor contracts, tenant or rental agreements: if you already track these in a spreadsheet, you probably also copy the last contract, swap the name, address, dates and fee by hand, and hope nothing from the previous client slips through. This guide turns each row into its own contract instead. You keep one Google Docs template with {{placeholders}}, tick a box on a row, and a free Apps Script creates the Google Doc and a PDF in a Drive folder, gives it a contract number that never changes, and writes both links back to the row. Rows that are already done are skipped, so running it twice is safe.
Not legal advice. This guide is about the mechanics of filling in a template. It doesn't tell you what a contract should say. Have your template reviewed by a lawyer licensed where you and the other party are, especially for leases, employment and anything with local rules, and re-check it when the law or your terms change.
Step 1: one row per agreement
Create a tab named Contracts. Each row is one agreement with one client, vendor, tenant or freelancer. The script needs these headers in row 1 (any order): Name, Contract #, Generate, Status, Doc link, PDF link. Add whatever your template uses, for example Address, Start date, Fee, Term and Late fee.
| Name | Address | Start date | Fee | Term | Late fee | Contract # | Generate | Status | Doc link | PDF link |
|---|---|---|---|---|---|---|---|---|---|---|
| Dana Lee | 12 Oak St, Springfield | 12/1/2026 | 1250 | 12 months | 75 | ☑ | ||||
| Northwind LLC | 9 River Rd, Riverton | 1/5/2027 | 980.50 | 6 months | C-1007 | ☑ | ||||
| Sam Ortiz | 1 Elm Ave, Lakeside | 1/15/2027 | 500 | 3 months | ☐ |
- Generate is a checkbox column: select it and choose Insert › Checkbox. Tick it when a row is checked and ready. Nothing happens to unticked rows, so drafts can sit in the sheet safely.
- Status, Doc link and PDF link start empty. The script fills them. A row with a PDF link counts as done and is never regenerated.
- Keep Start date as real dates and Fee as plain numbers. The script formats them (next step), and you can still sort and total them in the sheet.
Step 2: dates, money and contract numbers
Dates and currency. A raw sheet value would put Tue Dec 01 2026 00:00:00 GMT-0500 or 1250 into a contract. The script below handles that: every date cell becomes December 1, 2026 (change DATE_FORMAT), and numbers in the columns listed in CURRENCY_COLUMNS become $1,250.00. If you prefer to control the format in the sheet, add a helper column and use that header in the template instead, for example Start date text with =TEXT(C2,"mmmm d, yyyy") and Fee text with =TEXT(D2,"$#,##0.00"). A text cell is inserted exactly as it appears.
Stable contract numbers. A contract number has to stay with its agreement forever, because people quote it in emails, invoices and renewals. Avoid =ROW()-style formulas: they change when rows are sorted, filtered into another tab or deleted. Instead, leave Contract # blank and let the script assign the next number (C-1001, C-1002…) as a plain value when the contract is generated. It looks at the highest number already in the column, so typed or imported numbers like C-1007 are respected and never reused. If you already have a numbering scheme, type the numbers yourself; the script keeps any number that's there.
Step 3: the Google Docs template
Create a Google Doc with your agreement text and put a placeholder wherever a row value goes. Placeholders are the column header in double curly braces, spelled exactly like row 1, including spaces and capitals: {{Name}}, {{Start date}}, {{Contract #}}. They work in the body, header and footer, and the same placeholder can appear more than once.
SERVICE AGREEMENT No. {{Contract #}}
This agreement is between Acme Studio ("Provider") and {{Name}},
{{Address}} ("Client"), starting {{Start date}} for a term of {{Term}}.
Fees. Client will pay {{Fee}} per month, due on the 1st.
[[if Late fee]] Late payment. Payments more than 5 days late incur a fee of {{Late fee}}.
Signed for Provider: ____________ Signed for Client: ____________
Optional clauses. A paragraph that starts with [[if Column]] is kept only when that row's column has a value. In the example, Dana's contract gets the late-fee paragraph (with the marker removed), and Northwind's doesn't, because its Late fee cell is empty. Keep it to one paragraph per marker, and use separate columns for separate clauses (for example a Deposit column for a deposit paragraph). If two versions of a contract differ a lot, two templates are easier to keep straight than many conditions.
Copy the template's ID from its URL (between /d/ and /edit), and the ID of the Drive folder for finished contracts (after /folders/).
Step 4: the script
In the sheet, open Extensions › Apps Script, replace the sample code with the script below, paste your two IDs into TEMPLATE_ID and FOLDER_ID, and save. Reload the sheet: a Contracts menu appears. Choose Generate ticked rows and approve the permissions the first time.
/**
* Contracts and agreements from a Google Sheets row and a Google Docs template.
* "Contracts" tab headers used (any order): Name, Contract #, Generate,
* Status, Doc link, PDF link. Every other column (Address, Start date, Fee,
* Term, Late fee...) can be used in the template as {{Header}}.
* Only rows with Generate ticked and an empty PDF link are processed.
*/
const SHEET_NAME = 'Contracts';
const TEMPLATE_ID = 'PASTE_TEMPLATE_DOC_ID';
const FOLDER_ID = 'PASTE_DRIVE_FOLDER_ID';
const DATE_FORMAT = 'MMMM d, yyyy'; // how date cells appear in the contract
const CURRENCY_COLUMNS = ['Fee', 'Late fee']; // numbers in these columns become $1,250.00
const CURRENCY_SYMBOL = '$';
const NUMBER_PREFIX = 'C-'; // new contract numbers: C-1001, C-1002...
const FIRST_NUMBER = 1001;
const MAX_PER_RUN = 25; // stop early; run again for the rest
const MAX_RUNTIME_MS = 4.5 * 60 * 1000; // Apps Script stops runs at 6 minutes
function onOpen() {
SpreadsheetApp.getUi().createMenu('Contracts')
.addItem('Generate ticked rows', 'generateContracts').addToUi();
}
function generateContracts() {
const started = Date.now();
const ss = SpreadsheetApp.getActive();
const sh = ss.getSheetByName(SHEET_NAME);
const rows = sh.getDataRange().getValues();
const head = rows[0].map((h) => String(h).trim());
const col = (name) => head.indexOf(name);
const c = {
name: col('Name'), num: col('Contract #'), gen: col('Generate'),
status: col('Status'), doc: col('Doc link'), pdf: col('PDF link'),
};
for (const k of Object.keys(c)) {
if (c[k] === -1) throw new Error('Missing column for ' + k + '. Check the headers in row 1.');
}
const put = (i, k, v) => { rows[i][c[k]] = v; sh.getRange(i + 1, c[k] + 1).setValue(v); };
const tz = Session.getScriptTimeZone();
const template = DriveApp.getFileById(TEMPLATE_ID);
const folder = DriveApp.getFolderById(FOLDER_ID);
// Highest number already used, so new numbers never repeat.
let last = FIRST_NUMBER - 1;
for (const r of rows.slice(1)) {
const m = String(r[c.num]).match(/(\d+)\s*$/);
if (String(r[c.num]).indexOf(NUMBER_PREFIX) === 0 && m) last = Math.max(last, Number(m[1]));
}
let made = 0, stopped = false;
for (let i = 1; i < rows.length; i++) {
const r = rows[i];
if (r[c.gen] !== true || r[c.pdf] !== '' || String(r[c.name]).trim() === '') continue;
if (made >= MAX_PER_RUN || Date.now() - started > MAX_RUNTIME_MS) { stopped = true; break; }
try {
if (String(r[c.num]).trim() === '') put(i, 'num', NUMBER_PREFIX + (++last));
const fileName = r[c.num] + ' - ' + r[c.name];
// Reuse a Doc left by an earlier run (link in the row, or same name in the folder).
let docFile = null;
const id = String(r[c.doc]).match(/\/d\/([\w-]+)/);
if (id) docFile = DriveApp.getFileById(id[1]);
if (!docFile) docFile = findInFolder_(folder, fileName, MimeType.GOOGLE_DOCS);
if (!docFile) docFile = template.makeCopy(fileName, folder);
if (!id) put(i, 'doc', docFile.getUrl());
const values = {};
head.forEach((h, j) => { if (h) values[h] = formatValue_(h, r[j], tz); });
const doc = DocumentApp.openById(docFile.getId());
applyConditions_(doc.getBody(), values);
for (const part of [doc.getBody(), doc.getHeader(), doc.getFooter()]) {
if (!part) continue;
for (const h of Object.keys(values)) replaceTag_(part, '{{' + h + '}}', values[h]);
}
doc.saveAndClose();
let pdf = findInFolder_(folder, fileName + '.pdf', MimeType.PDF);
if (!pdf) pdf = folder.createFile(docFile.getAs(MimeType.PDF)).setName(fileName + '.pdf');
put(i, 'pdf', pdf.getUrl());
put(i, 'status', 'Generated ' + Utilities.formatDate(new Date(), tz, 'yyyy-MM-dd'));
made++;
} catch (e) {
put(i, 'status', 'Error: ' + e.message);
if (/too many times|quota|limit exceeded/i.test(e.message)) { stopped = true; break; }
}
}
ss.toast(made + ' contract(s) generated.' + (stopped ? ' Run again to continue.' : ''));
return made;
}
// A paragraph that starts with [[if Late fee]] is kept only when that column has a value.
function applyConditions_(body, values) {
for (const p of body.getParagraphs()) {
const m = p.getText().match(/^\[\[if ([^\]]+)\]\]\s*/);
if (!m) continue;
if (String(values[m[1].trim()] || '').trim() === '') {
try { p.removeFromParent(); } catch (e) { p.setText(''); } // the last paragraph can't be removed
} else {
p.editAsText().deleteText(0, m[0].length - 1);
}
}
}
// Literal find-and-replace, so values containing $ or \ are inserted exactly.
function replaceTag_(part, tag, value) {
const pattern = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
for (let n = 0, found = part.findText(pattern); found && n < 500; n++, found = part.findText(pattern)) {
const text = found.getElement().asText();
const start = found.getStartOffset();
text.deleteText(start, found.getEndOffsetInclusive());
if (value !== '') text.insertText(start, value);
}
}
function formatValue_(header, v, tz) {
if (v instanceof Date) return Utilities.formatDate(v, tz, DATE_FORMAT);
if (typeof v === 'number' && CURRENCY_COLUMNS.indexOf(header) !== -1) {
return CURRENCY_SYMBOL + v.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
return v === null || v === undefined ? '' : String(v);
}
function findInFolder_(folder, name, mimeType) {
const files = folder.getFilesByName(name);
while (files.hasNext()) {
const f = files.next();
if (f.getMimeType() === mimeType && !f.isTrashed()) return f;
}
return null;
}
What it does, row by row:
- Skips rows that aren't ticked, rows without a Name, and rows that already have a PDF link.
- Numbers the row if Contract # is blank, then copies the template into your folder as
C-1001 - Dana Leeand writes the Doc link straight away. - Fills optional clauses first, then every
{{Header}}in the body, header and footer. Values are inserted literally, so a$or a backslash in a cell doesn't confuse the replacement. - Saves the Doc, makes the PDF next to it, writes the PDF link and sets Status to
Generatedwith the date.
Running it twice. Finished rows have a PDF link, so a second run does nothing to them. If a run stopped halfway through a row (a timeout, a quota error, a closed tab), the next run finds that row's Doc from the Doc link, or from a file with the same name in the folder, and finishes it instead of making a second copy.
Quotas and time limits. Apps Script stops a run after six minutes, and Google limits how many documents an account can create per day (see Apps Script quotas). The script stops on its own after MAX_PER_RUN contracts or about four and a half minutes, and stops immediately if Google reports a quota error, leaving the error in that row's Status. Run it again later and it carries on from where it stopped.
Signing
The script only creates the documents; it doesn't send them or collect signatures. Two common options:
- Google Docs eSignature. Google offers eSignature in Docs and for PDFs in Drive on some plans: Workspace Individual, and Business Standard and Plus, Enterprise and Education Plus editions when the administrator enables it, according to Google's eSignature help page. On those plans you can open a generated Doc or PDF and use Tools › eSignature to add signature fields and request signatures. Check that page for the current list, since it changes.
- Any other e-signature tool. Otherwise, send the PDF with your e-signature tool, or print and sign. Once it's signed, save the signed copy next to the generated one and update Status (for example
Signed).
Tips
- Test with a fake row first. Add a row for yourself, generate it, and read the PDF line by line. Look for leftover
{{(a placeholder that doesn't match a header exactly) and for the optional clauses you expect. - Changing a contract after it's generated. Don't edit the PDF. Fix the row, delete the old Doc and PDF (or rename them, for example with “superseded”), clear Doc link, PDF link and Status, and generate again. If the other party already has the first version, give the new one a revision number such as
C-1001-R1so both sides can tell them apart. - Template versions. When you change the wording, make a copy of the template with a version in its name and point
TEMPLATE_IDat the new one. Old contracts keep the wording they were signed with, and you can add a Template column to record which version each row used. - Privacy. The sheet and folder hold names, addresses and fees. Share them only with the people who prepare contracts, and remember that anyone with access to the folder can open every agreement in it.
- Renewals. Once agreements have end dates, see contract renewal and expiration alerts to get an email before each one runs out, and emailing each row's PDF as an attachment if you want to send the generated PDFs from the sheet.
- Other documents from rows. The same method makes offer letters, quotes and invoices; the general version is a PDF or Google Doc for each row.
{{Column}} placeholders from the row; for the [[if]] clauses and automatic numbering above, use the script, or put the clause text and the contract number in columns of their own. It is on the Google Workspace Marketplace.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
- Make a PDF progress report for each student
- Generate offer letters from a Google Sheets hiring tracker
- Generate contracts and agreements from a Google Docs template
- 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
- Equipment sign-out sheet with due-back and overdue 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
Not legal advice; have any contract template reviewed by a qualified lawyer before use. Instructions refer to Google Sheets, Google Docs and Apps Script as they worked on November 14, 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.