Copy Google Docs Heading Styles with Apps Script

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:

  1. Automated approach: Apply the source’s paragraph/text styles to every matching heading in the target doc.
  2. 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 of HEADING_1, HEADING_2, … etc.
  • Sends updateParagraphStyle and updateTextStyle requests with fields: "*" (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 loops body.content. To hit tables, iterate through table rows/cells recursively. For headers/footers: loop through tgt.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:

  1. Script injects a block of sample headings at the end of the target doc.
  2. Each sample paragraph is styled to match the source.
  3. 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

  1. Scroll to the bottom of your target document.
  2. Click somewhere in the “Heading 2 Sample” line.
  3. 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?

ScenarioSolution
You just need existing headings to visually matchOption 1 (auto apply)
You need the actual “named styles” updated for future useOption 2 (sampler + manual update)
You’re copying entire docs or templatesConsider duplicating the source doc and moving content instead
Headings inside tables/headersExtend 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 with UrlFetchApp 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.