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.

NameAddressStart dateFeeTermLate feeContract #GenerateStatusDoc linkPDF link
Dana Lee12 Oak St, Springfield12/1/2026125012 months75☑
Northwind LLC9 River Rd, Riverton1/5/2027980.506 monthsC-1007☑
Sam Ortiz1 Elm Ave, Lakeside1/15/20275003 months☐

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:

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:

Tips

Want contracts from rows without maintaining a script? SheetWeave (disclosure: we make it) is a Google Sheets add-on that fills a Google Docs template with each row's column values and saves a Doc, a PDF, or both in a Drive folder you choose, writing the links back to the row. It fills {{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

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.