// Docs · Apps Script

Append a table to a Google Doc in Google Docs.

How to use Google Apps Script's appendTable() to insert a formatted table at the end of a Google Doc, including the required 2D string array format and type-casting gotchas.

I want to programmatically insert a table into a Google Doc using Apps Script without manually formatting cells one by one.

The script

copy · paste · trigger
appendTable.gs
Apps Script
// Append a summary table to the active Google Doc
function appendSummaryTable() {
  var doc = DocumentApp.getActiveDocument();
  var body = doc.getBody();

  var rows = [
    ['Item', 'Qty', 'Unit Price', 'Total'],
    ['Widget A', String(12), String(4.99), String(59.88)],
    ['Widget B', String(3), String(14.00), String(42.00)],
    ['Widget C', String(7), String(2.50), String(17.50)]
  ];

  var table = body.appendTable(rows);

  // Bold the header row
  var headerRow = table.getRow(0);
  for (var i = 0; i < headerRow.getNumCells(); i++) {
    headerRow.getCell(i).getText();
    headerRow.getCell(i).setBackgroundColor('#e8eaed');
  }

  doc.saveAndClose();
}

Need a variant? Gnaw writes a custom version from one sentence — fields, triggers, edge cases handled.

Walkthrough

What appendTable actually expects

The Body class in Apps Script exposes appendTable() with two signatures: no arguments (creates an empty table you populate cell by cell afterward) and a 2D array of strings. The second form is what you want when you already have data — it creates the table and fills every cell in a single call.

The word 'strings' is load-bearing. The parameter type is String[][], not any[][], and the runtime enforces it strictly. Pass a raw number like 12 and you get a TypeError at the appendTable() call, not a silent coercion. This is the first place people get burned coming from Sheets, which accepts mixed-type ranges without complaint.

Wrap every non-string value in String() before building the array. For Dates, String(new Date()) gives you a locale-formatted string; if you want a specific format, run Utilities.formatDate() first and pass that result instead.

The table lands at the very end of the document body, after the last paragraph. If you need it inserted at a specific position — say, after a named bookmark — you have to use insertTable(childIndex, rows) instead, which takes a zero-based index into the body's child elements.

Styling cells after the fact

appendTable() returns a Table object. From there, getRow(index) gives a TableRow, and getCell(index) on that gives a TableCell. All font, background, and border styling goes through these objects after the table exists — the initial 2D array only sets text content.

The snippet above sets a light grey background on the header row with setBackgroundColor('#e8eaed'). If you want bold text in those cells, call getText() to get the cell's text content, then work through getChild(0).asText().setBold(true) — TableCell.getChild(0) is the Paragraph element holding the text, and you need the Text object off that to apply character-level formatting.

I keep a small helper function in a utils file that accepts a Table and a hex color string and shades row 0 — it comes up in almost every report script I write. One function, called once, saves manually indexing the header on every project.

When the table needs to repeat across runs

If your script runs on a schedule (say, a weekly summary appended to a running log doc), appendTable() will pile up a new table on every execution. Whether that is the intended behavior depends entirely on your use case.

A common pattern for log-style docs is to search the body for an existing table by checking body.getNumChildren() and inspecting element types with getChild(i).getType() === DocumentApp.ElementType.TABLE before appending. If you find one at a known position, clear its rows with removeRow() in a loop (iterating from the last row index downward to avoid index-shift bugs) and re-populate. This avoids document bloat and keeps the doc readable as a single canonical view.

For snapshot-style docs where each run should produce a fresh file, the cleaner approach is DocumentApp.create() with a new name per run rather than appending to a shared doc.

Want a custom version?

Describe your sheet and the rule you want. Gnaw writes the Apps Script — fields, triggers, edge cases — in one shot.

FAQ

4 questions
Why does appendTable throw 'Cannot convert Array to String'?
One or more cells in your 2D array is not a string. Numbers, booleans, Dates, and null all trigger this. Wrap every value in String() when building the array — String(0) and String(null) both produce safe strings.
How do I insert a table at a specific position, not at the end?
Use body.insertTable(childIndex, rows) instead of appendTable(). The childIndex is the position among all body children (paragraphs, images, other tables). Get the index of a reference element with body.getChildIndex(element), then pass index + 1 to insert immediately after it.
Can I set column widths when creating the table?
Not through the initial appendTable() call. After the table is created, call table.getRow(0).getCell(i).setWidth(pixels) on each cell in the first row. Column width in Docs is controlled per-cell, not per-column, so you set it on every cell in the column if you want uniformity.
Does appendTable work in a Google Doc bound script and a standalone script?
Both work, with one difference: DocumentApp.getActiveDocument() only works in a container-bound script (or when the script is run from within a Docs UI). In a standalone script, use DocumentApp.openById('your-doc-id') or DocumentApp.openByUrl() instead.