Why This Post Exists
If you’ve tried to clone heading styles (H1–H6, Title, Subtitle) from one Google Doc to another using Apps Script, you probably hit this wall:
Invalid JSON payload received. Unknown name "updateNamedStyles"...
Yep—updateNamedStyles
isn’t supported for write operations in the Docs API (at least for now). You can read the named styles, but you can’t redefine them globally in another doc.
So here are two reliable workarounds:
- Automated approach: Apply the source’s paragraph/text styles to every matching heading in the target doc.
- Manual fallback: Append a “style sampler” block to the end of the target doc. Then, use Google Docs’ built-in “Update Heading X to match selection.”
Prerequisites
- Apps Script project (standalone or bound to a doc).
- Advanced Google service: Docs API enabled
- In Apps Script: Services → Advanced Google services… → Google Docs API → Add
- In Cloud Console (if needed): enable the Docs API.
- Scope:
https://www.googleapis.com/auth/documents
(Apps Script will prompt you).
Option 1: Force-Apply Heading Styles Automatically
This script:
- Reads the source doc’s named styles.
- Finds all paragraphs in the target doc that have a
namedStyleType
ofHEADING_1
,HEADING_2
, … etc. - Sends
updateParagraphStyle
andupdateTextStyle
requests withfields: "*"
(simple and safe). - Sends requests in chunks to avoid the API’s 100-request limit per
batchUpdate
.
/**
* Copy heading styles (Title, Subtitle, H1–H6) from source to target Doc.
* Replace SOURCE_ID and TARGET_ID with your doc IDs.
*/
function copyHeadingStyles() {
const SOURCE_ID = 'PUT_SOURCE_DOC_ID_HERE';
const TARGET_ID = 'PUT_TARGET_DOC_ID_HERE';
const HEADINGS = ['TITLE','SUBTITLE','HEADING_1','HEADING_2','HEADING_3','HEADING_4','HEADING_5','HEADING_6'];
// 1. Read source named styles
const src = Docs.Documents.get(SOURCE_ID);
const styleMap = buildStyleMap_(src.namedStyles, HEADINGS);
// 2. Read target structure
const tgt = Docs.Documents.get(TARGET_ID);
// 3. Build update requests
const requests = [];
(tgt.body.content || []).forEach(el => {
if (!el.paragraph) return;
const type = el.paragraph.paragraphStyle?.namedStyleType;
if (!type || !styleMap[type]) return;
const range = { startIndex: el.startIndex, endIndex: el.endIndex };
const { paragraphStyle, textStyle } = styleMap[type];
if (paragraphStyle && Object.keys(paragraphStyle).length) {
requests.push({
updateParagraphStyle: {
range,
paragraphStyle,
fields: '*'
}
});
}
if (textStyle && Object.keys(textStyle).length) {
requests.push({
updateTextStyle: {
range,
textStyle,
fields: '*'
}
});
}
});
Logger.log('Requests to send: ' + requests.length);
pushInChunks_(TARGET_ID, requests, 90);
Logger.log('Heading styles copied!');
}
/** Build a lookup map of desired named styles */
function buildStyleMap_(namedStyles, allowed) {
const map = {};
(namedStyles?.styles || []).forEach(s => {
if (allowed.indexOf(s.namedStyleType) === -1) return;
map[s.namedStyleType] = {
paragraphStyle: s.paragraphStyle || {},
textStyle: s.textStyle || {}
};
});
return map;
}
/** Send batchUpdate requests in chunks */
function pushInChunks_(docId, requests, size) {
for (let i = 0; i < requests.length; i += size) {
Docs.Documents.batchUpdate(
{ requests: requests.slice(i, i + size) },
docId
);
Utilities.sleep(200); // be polite to the API
}
}
Troubleshooting
- “Nothing changed”
Make a very obvious style tweak in the source (e.g., neon green H2, 48pt font), then re-run. If you still see no difference, either your target headings aren’t actually set to Heading styles (they might be Normal text with manual formatting), or they live in containers you didn’t loop (tables, headers, footers, footnotes). - Headings in tables/headers
The sample only loopsbody.content
. To hit tables, iterate through table rows/cells recursively. For headers/footers: loop throughtgt.headers
,tgt.footers
and repeat the same logic. - Quota limits
Large docs mean many requests. Chunking +Utilities.sleep()
helps. Consider deduplicating ranges or checking if styles already match to reduce calls.
Option 2: Insert a “Style Sampler” Block for Manual Updating
When you just want to quickly port styles without fiddling with code quirks, this method works great:
- Script injects a block of sample headings at the end of the target doc.
- Each sample paragraph is styled to match the source.
- In the doc UI, place your cursor on each sample and do:
Format → Paragraph styles → Heading X → Update ‘Heading X’ to match selection
function appendHeadingSampler() {
const SOURCE_ID = 'PUT_SOURCE_DOC_ID_HERE';
const TARGET_ID = 'PUT_TARGET_DOC_ID_HERE';
const HEADINGS = ['TITLE','SUBTITLE','HEADING_1','HEADING_2','HEADING_3','HEADING_4','HEADING_5','HEADING_6'];
const src = Docs.Documents.get(SOURCE_ID);
const styleMap = buildStyleMap_(src.namedStyles, HEADINGS);
const tgt = Docs.Documents.get(TARGET_ID);
const end = tgt.body.content[tgt.body.content.length - 1].endIndex;
const requests = [];
let cursor = end - 1;
// Insert a label
const headerText = '\n\n=== STYLE SAMPLER ===\n';
requests.push({
insertText: {
location: { index: cursor },
text: headerText
}
});
cursor += headerText.length;
// Insert each heading sample and style it
HEADINGS.forEach(h => {
const styles = styleMap[h];
if (!styles) return;
const label = h.replace('_',' ');
const sampleText = `${label} Sample\n`;
requests.push({
insertText: {
location: { index: cursor },
text: sampleText
}
});
const start = cursor;
const endIdx = cursor + sampleText.length;
cursor = endIdx;
if (styles.paragraphStyle && Object.keys(styles.paragraphStyle).length) {
requests.push({
updateParagraphStyle: {
range: { startIndex: start, endIndex: endIdx },
paragraphStyle: styles.paragraphStyle,
fields: '*'
}
});
}
if (styles.textStyle && Object.keys(styles.textStyle).length) {
requests.push({
updateTextStyle: {
range: { startIndex: start, endIndex: endIdx },
textStyle: styles.textStyle,
fields: '*'
}
});
}
});
Docs.Documents.batchUpdate({ requests }, TARGET_ID);
Logger.log('Sampler inserted.');
}
// Reuse the buildStyleMap_ helper from above
function buildStyleMap_(namedStyles, allowed) {
const map = {};
(namedStyles?.styles || []).forEach(s => {
if (allowed.indexOf(s.namedStyleType) === -1) return;
map[s.namedStyleType] = {
paragraphStyle: s.paragraphStyle || {},
textStyle: s.textStyle || {}
};
});
return map;
}
Using the Sampler
- Scroll to the bottom of your target document.
- Click somewhere in the “Heading 2 Sample” line.
- Format → Paragraph styles → Heading 2 → Update ‘Heading 2’ to match selection
Repeat for each heading level you care about.
This gives you true named-style updates, so new headings you create later will inherit the transferred formatting.
Which Approach Should You Use?
Scenario | Solution |
---|---|
You just need existing headings to visually match | Option 1 (auto apply) |
You need the actual “named styles” updated for future use | Option 2 (sampler + manual update) |
You’re copying entire docs or templates | Consider duplicating the source doc and moving content instead |
Headings inside tables/headers | Extend the walker to those containers |
Bonus Tips
- Clone instead of restyle: If you’re starting a new doc, it’s often simpler to duplicate the source document (
Drive.Files.copy
) and paste or programmatically insert your new content. - Partial styles: Want only H2 and H3? Trim the
HEADINGS
array. - Audit/debug:
Logger.log(JSON.stringify(obj, null, 2));
is your friend for inspecting styles. - Use
muteHttpExceptions: true
withUrlFetchApp
if you go the raw REST route—useful to read full error payloads.
Wrap-Up
Google Docs’ API gives you read access to named styles, but not a direct setter. Until that changes, these two patterns—automated force-apply or manual sampler—are the most reliable ways to “copy styles” between Docs with Apps Script.
