Originally written for bulldo.gs — republished here with the canonical link pointing home.
I want to programmatically combine several Google Docs into one file without losing tables or list formatting.
// Merges all source docs into destDocId, in order.
// Run from the Apps Script editor; no triggers needed.
function mergeDocs() {
var sourceIds = [
'DOC_ID_ONE',
'DOC_ID_TWO',
'DOC_ID_THREE'
];
var dest = DocumentApp.openById('DEST_DOC_ID').getBody();
for (var i = 0; i < sourceIds.length; i++) {
var srcBody = DocumentApp.openById(sourceIds[i]).getBody();
var total = srcBody.getNumChildren();
for (var j = 0; j < total; j++) {
var el = srcBody.getChild(j);
var type = el.getType();
if (type === DocumentApp.ElementType.PARAGRAPH) {
dest.appendParagraph(el.asParagraph().copy());
} else if (type === DocumentApp.ElementType.TABLE) {
dest.appendTable(el.asTable().copy());
} else if (type === DocumentApp.ElementType.LIST_ITEM) {
dest.appendListItem(el.asListItem().copy());
}
}
}
}
Why there is no single appendElement call
The Document service in Apps Script does not expose a generic appendElement method on Body. Every element type has its own typed append method: appendParagraph, appendTable, appendListItem, and so on. That means a merge loop that ignores element types will throw TypeError: el.copy is not a function the moment it hits a table, because you would be passing an Element where the API expects a Table.
The fix is to call getType() on each child element and switch on DocumentApp.ElementType. The type enum values are strings like PARAGRAPH, TABLE, LIST_ITEM, INLINE_IMAGE, and HORIZONTAL_RULE. In practice the first three account for almost all real document content. The code above handles those three and silently skips anything else (images, rules) rather than crashing the entire merge.
Getting the doc IDs and running the script
The ID for any Google Doc is the long string between /d/ and /edit in its URL. Paste those into the sourceIds array and put the destination doc's ID in 'DEST_DOC_ID'. The destination doc must already exist; the script does not create it.
Open script.google.com, create a new project, paste the function, save, and click Run. The first run will trigger an OAuth consent screen asking for permission to read and modify your Docs. Accept it. Subsequent runs execute without prompting. I keep this script bound to the destination doc (via Extensions > Apps Script from within that doc) so the project stays easy to find later.
One quota note: DocumentApp.openById counts against the Apps Script read quota, which is 50 opens per execution for consumer accounts and higher for Workspace. For more than roughly 40 source docs, batch across multiple executions or use a time-driven trigger that processes a slice at a time.
Preserving list grouping across source documents
List items in Google Docs carry a list ID that ties them to a specific list group. When you copy a ListItem from one document into another, Apps Script assigns it a new list ID automatically, so items from different source docs will not accidentally merge into the same numbered list. That behavior is what you want.
Where it surprises people: if a single source doc has two separate bullet lists separated by a paragraph, and that paragraph is a heading (which Apps Script still sees as PARAGRAPH), the heading copies correctly and the two lists stay separate. The problem only appears if you skip paragraph elements, which drops the visual boundary and makes two distinct lists appear to run together in the output. Copy everything in child order and this does not happen.
FAQ
Does this preserve bold, italic, and heading styles?
Yes. The .copy() call on a paragraph or list item carries over all inline text attributes (bold, italic, underline, font size) and the paragraph style (Heading 1, Normal, etc.). What it does not carry over is named styles you defined via a custom Style; those stay tied to the source document's stylesheet.
Why does my table appear empty after the merge?
You likely called appendTable with the raw Element object rather than casting it first with el.asTable(). The cast returns a proper Table instance whose .copy() includes all rows and cells. Skipping the cast passes an untyped element and the API creates an empty table shell.
Can I merge docs from a shared drive I do not own?
Yes, as long as the account running the script has at least Viewer access on each source doc. The OAuth scope https://www.googleapis.com/auth/documents covers cross-ownership reads. If a source doc is restricted and the script account lacks access, openById throws a GoogleJsonResponseException with a 403; fix it by sharing the source doc with the script-runner's account.
The script stops after a few docs with a 'Service invoked too many times' error.
That is the Apps Script DocumentService quota cap. For consumer (free) Google accounts, the daily limit is 250 document opens. For Google Workspace accounts it is higher but still finite. Split your source IDs into batches of 20-30 and run the function multiple times, or set a time-driven trigger that processes one batch per minute.
Want the plain-English version? Describe the automation at bulldo.gs and get working Apps Script back — free, no login.
Top comments (0)