// Docs · Apps Script

Number the headings in a Google Doc with Apps Script.

Walk the body paragraphs of a Google Doc, detect each HEADING type, and prepend an incrementing counter using Apps Script — since Docs has no built-in heading numbering.

I want to automatically prefix numbered counters to every heading in my Google Doc so I do not have to maintain them by hand every time I add or move a section.

The script

copy · paste · trigger
numberHeadings.gs
Apps Script
// Prefix an incrementing counter to every heading in the active Doc.
// Run numberHeadings() from the Apps Script editor or a custom menu.
function numberHeadings() {
  var doc = DocumentApp.getActiveDocument();
  var body = doc.getBody();
  var paras = body.getParagraphs();
  var count = 0;

  for (var i = 0; i < paras.length; i++) {
    var para = paras[i];
    var type = para.getHeadingAttributes()[DocumentApp.Attribute.HEADING];
    var isHeading = type !== DocumentApp.ParagraphHeading.NORMAL &&
                    type !== DocumentApp.ParagraphHeading.TITLE;
    if (!isHeading) continue;

    count++;
    var text = para.getText();
    // Strip any existing leading counter before re-applying.
    text = text.replace(/^\d+\.\s*/, '');
    para.setText(count + '. ' + text);
  }
}

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

Walkthrough

Why the API works this way

Google Docs exposes the document as a tree of structural elements. Paragraphs sit directly under the body and carry a HEADING attribute that maps to the ParagraphHeading enum: HEADING1 through HEADING6, plus NORMAL and TITLE. There is no built-in outline-numbering option, so every heading counter you see in the wild was either typed by hand or injected by a script.

Calling body.getParagraphs() returns a flat array of every paragraph in reading order, which makes the walk straightforward. The first time I tried this, I naively checked para.getHeading() and got undefined on every element — the correct call is para.getHeadingAttributes()[DocumentApp.Attribute.HEADING], which returns the enum constant you can compare against DocumentApp.ParagraphHeading.NORMAL.

Stripping stale numbers before re-running

The strip step — text.replace(/^\d+\.\s*/, '') — is what keeps the script safe to run more than once. Without it, you accumulate prefixes: '1. Introduction' becomes '1. 1. Introduction' on the second run.

The regex matches one or more digits, a literal dot, and optional whitespace at the start of the string. That covers '1. ', '12. ', and '1.' with no trailing space. It does not touch headings that open with a year like '2026: Annual Review' because those require a dot immediately after the digits — adjust the pattern to /^\d+[.:]\s*/ if your doc uses colons.

One edge to know: setText() strips all character-level formatting (bold, italic, font size) from the paragraph text and replaces it with the body default. If your heading text has inline formatting inside it, you will lose it. The heading paragraph style itself (the HEADING1 style with its font and size) is preserved because that lives on the paragraph, not the text run.

Wiring it to a custom menu

Running the script manually from the editor is fine during setup, but in practice you want a menu item. Add an onOpen trigger function alongside numberHeadings:

function onOpen() { DocumentApp.getUi().createMenu('Tools Extra').addItem('Number headings', 'numberHeadings').addToUi(); } This fires automatically when the document opens and puts 'Number headings' under a 'Tools Extra' menu. The trigger runs as the document owner, so collaborators with Editor access can also invoke it — they are not blocked by the script's authorization scope, which is just the active document.

If the doc lives in a Shared Drive and you need all editors to have the menu, you have to publish the script as an add-on or use a bound script owned by the drive. A bound script owned by one person does not auto-run onOpen for other editors in Shared Drives — it does in regular My Drive shared files.

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
Does this script support multi-level numbering like 1.1, 1.2, 2.1?
Not as written. The single counter treats HEADING1 through HEADING6 identically. For hierarchical numbering, maintain a separate counter per heading level, reset lower-level counters when a higher level increments, and build the prefix by joining the non-zero counters with dots.
Why does setText() remove my bold text inside the heading?
setText() replaces the entire text content of the paragraph and discards all inline text runs, which is where bold/italic/color are stored. The paragraph style (font family, size, heading level) survives. If you need to preserve inline formatting, use appendText() on the existing text element to prepend the counter as a new run, or read and rewrite each child text element individually.
Can I run this on a specific range of headings rather than the whole document?
Yes. Replace body.getParagraphs() with a named range: DocumentApp.getActiveDocument().getNamedRanges('section') returns RangeElement arrays you can filter down to paragraphs, then apply the same heading check.
The script errors with 'Cannot read properties of null' on getHeadingAttributes. What is wrong?
Some list-item paragraphs return null from getHeadingAttributes(). Add a null guard before the attribute lookup: if (!para.getHeadingAttributes()) continue; placed immediately after var para = paras[i]; will skip those elements cleanly.
// one good script a week

Get a working Apps Script snippet in your inbox, weekly.