How the Markdown TOC generator works

Paste your whole document into the box at the top of this page, or open the .md file directly. The generator scans it line by line for ATX headings, the ones written with leading hash characters, from # Title down to ###### Small heading. Everything else is ignored, so the length of the document does not matter.

In short
  • Only hash-style headings are read; body text, code and images are skipped.
  • A minimum level of 2 drops the document title, and a maximum of 3 keeps a long file's list short.
  • Each entry links to the anchor ID GitHub would generate, with -1 added when two headings share a name.
  • The scan runs in your browser, so an unpublished spec never leaves the tab.

What the two level controls do

The minimum level sets the shallowest heading included. Set it to 2 and the single # title at the top of the file drops out. That is almost always what you want, because a table of contents rarely needs to link to the document it sits in.

The maximum level sets the deepest. Stopping at 3 keeps the list readable in a long specification, where level-four headings would easily triple its length.

What each heading turns into

Every surviving heading becomes one bullet. The link text is the heading text with inline formatting removed. The target is the anchor ID that GitHub would create for it. Indentation follows the hierarchy at two spaces per level, which produces a nested list that renders correctly everywhere.

Where the finished list goes

Copy it and paste it near the top of your file, under a short ## Contents heading. Want to check it first? Drop the file into the Markdown editor and read the list against the live preview. The rest of the Markdown tools take the same file when you need to convert or export it.

Tip: Generate the list last, after the writing is finished. A TOC built halfway through a draft is out of date by the time you publish.

Illustration of a long Markdown document whose headings condense into an indented list of linked entries

A worked example, headings in and TOC out

Here is a short handbook. It has one level-one title, level-two sections, level-three subsections and a level-four heading buried inside:

# Deploy Handbook

Everything the team needs before shipping.

## Getting started

### Install the CLI

### Options

## Continuous delivery

### Options

#### Retry policy

## FAQ: what breaks & why

Set the minimum level to 2 and the maximum to 3, and the generator returns this:

- [Getting started](#getting-started)
  - [Install the CLI](#install-the-cli)
  - [Options](#options)
- [Continuous delivery](#continuous-delivery)
  - [Options](#options-1)
- [FAQ: what breaks & why](#faq-what-breaks--why)

Six lines, and four separate decisions are visible in them.

The four decisions in that output

  • The title is gone. # Deploy Handbook sits above the minimum level, so it was excluded.
  • The deepest heading is gone too. #### Retry policy falls past the maximum level.
  • The second Options points at #options-1. Anchor IDs have to be unique within a page, so duplicates are numbered.
  • The ampersand left -- behind. It looks like a typo in the last anchor, and it is exactly right.

From paste to published, step by step

  1. Paste the document into the box at the top of this page.
  2. Set the minimum level to 2 so the document title drops out.
  3. Set the maximum level to 3, or to 4 if the file is short.
  4. Copy the list and paste it under a ## Contents heading near the top of the file.
  5. Publish, click one entry, and check the address bar matches.

Why every entry is just an ordinary link

Each bullet is a plain Markdown link whose target is a fragment identifier rather than a URL. That is the entire mechanism. [label](#anchor) jumps to the heading whose generated ID is anchor, inside the same file. Nothing platform-specific is involved, which is why a generated TOC keeps working after you move the document, as long as the new renderer builds IDs the same way.

How GitHub turns a heading into an anchor ID

The link target is not something you invent. It is derived from the heading text by a fixed recipe, and knowing the recipe is what lets you spot a broken link by eye.

The four rules, in order

  1. Lowercase everything.
  2. Delete anything that is not a letter, a number, a space, a hyphen or an underscore.
  3. Replace each remaining space with a hyphen.
  4. If the page has used that ID already, append -1, then -2, in order of appearance.

Real headings and the anchors they produce

Written out, the rules sound trivial. In practice the second one surprises people constantly, because punctuation is deleted rather than replaced. This is what actually comes out:

Heading in your MarkdownGenerated anchorWhy
## Getting started#getting-startedLowercased, the space becomes a hyphen
## API Reference#api-referenceCapital letters are folded down
## FAQ: common questions#faq-common-questionsThe colon is deleted, not replaced
## What's new?#whats-newApostrophe and question mark both vanish
## Node.js & npm#nodejs--npmThe dot goes, and the deleted ampersand leaves two hyphens
## Step 1: install#step-1-installDigits survive unchanged
## C++ vs C##c-vs-cSymbols disappear, so similar headings collide easily
## 🚀 Deploy#-deployThe emoji is removed but its trailing space still becomes a hyphen
## 安裝說明#安裝說明CJK characters are kept, then percent-encoded in the address bar
## Café résumé#café-résuméAccented letters are letters, so they stay
## **Bold** heading#bold-headingInline formatting is stripped before the ID is built
## Options (second one)#options-1Duplicates are numbered in document order

Which renderers follow the same recipe

GitLab, Gitea, Docusaurus and most static site builders use the same rules. A list made here works in all of them with no edits. Bitbucket and some older wikis do not follow the rules, and a few renderers add a prefix such as user-content- to every ID. The complete Markdown guide covers headings in full, and GitHub Flavored Markdown names the dialect this rule comes from.

Watch out: Never guess an anchor for a heading full of punctuation. Publish the page, click the link once, and read the ID out of the address bar. Two seconds beats a link that quietly does nothing.

GitHub builds its own TOC now, so when do you need this?

Worth saying plainly: on GitHub itself, a hand-made table of contents is now mostly redundant. Every rendered Markdown file gets a contents button in its header. The button opens an outline of the headings in that file. It never falls behind, because GitHub builds it fresh each time you look.

Five places that button does not reach

It covers one surface. A Markdown file ends up in many others, which is why generating a list is still worth four seconds:

  • Other hosts. Self-hosted GitLab, Gitea, Codeberg and internal wikis vary in what they offer, and several offer nothing.
  • Static sites. Some Hugo, Jekyll and Astro themes render a sidebar outline. Plenty do not, and a list inside the file works regardless of theme.
  • Long documents outside a repository. A specification or runbook read in an editor, in Obsidian, or in the Markdown viewer has no header button to press.
  • Converted output. When the file becomes HTML or is saved as a PDF, an inline TOC survives and an interface feature does not.
  • The raw file. Anyone opening the .md in a terminal or a diff sees the structure straight away.

A rule of thumb worth following

Skip the manual list for a short README that lives only on GitHub. Generate one for anything past roughly a thousand words, and for anything that will be read somewhere else. Then regenerate it whenever you add or rename a section, which costs one paste.

What goes wrong, and the fix

Broken tables of contents fail in a small number of ways, and the symptom tells you which one you have.

The anchor does not match the heading. Usually the cause is punctuation: someone wrote the link by hand and kept a character that the ID rules delete.

<!-- broken: the colon and the capital letters survived -->
- [FAQ: Setup](#FAQ:-Setup)

<!-- correct -->
- [FAQ: Setup](#faq-setup)

Two links landing on the same place is the related fault. Two headings share the same text, so the second needs its -1 suffix. Regenerating fixes it, but renaming one heading is better: a document with two sections called "Options" is hard to navigate however good the links are.

A heading is missing from the list

Check three things, in this order:

  • Is it inside a fenced code block? There a # is a comment, not a heading.
  • Is it a Setext heading, underlined with === or --- instead of hashes?
  • Is the space after the hashes missing? ##Setup is literal text in most parsers, while ## Setup is a heading.

The nesting looks wrong

Indentation mirrors the heading levels in the file. A level-two section followed directly by a level-four heading makes the list jump two steps at once. Fix the document rather than the list, because skipping levels also confuses screen readers and search engines. The lists guide covers the nesting rules in detail.

The list drifts, or breaks after a move

Drift is the real cost of a manual list, and the cure is to rebuild rather than patch. Paste the current file in, copy the new list out, replace the old one. Two minutes a month keeps it honest.

A renderer that builds IDs its own way is the harsher version of the same fault, since it breaks every link at once. So click one link in the destination before you trust the list, and keep the cheat sheet to hand for edits. If the file also carries tables lifted from a spreadsheet, the table to Markdown converter deals with that part.

Support data verified

Frequently Asked Questions

How do I make a table of contents in Markdown?

Write a bullet list of links whose targets are anchor IDs: - [Getting started](#getting-started), indenting nested entries by two spaces per level. Paste your document into the generator above and the list is built for you.

How does GitHub create the anchor for a heading?

It lowers the case of the heading text. Then it deletes anything that is not a letter, a number, a space, a hyphen or an underscore. Each space left becomes a hyphen. Repeat headings get -1, then -2. So ## FAQ: common questions becomes #faq-common-questions.

Does GitHub already generate a table of contents?

Yes. Rendered files on GitHub have a contents button in the file header, and it opens an outline of the headings. A manual list still helps on other hosts, on static sites, in PDF and HTML exports, and in long files read away from GitHub.

Can I limit which heading levels appear?

Yes. Set the minimum and maximum heading level before you generate. A common pair is minimum 2, which drops the file title, and maximum 3, which keeps the list short in a long file.

Do the anchor links work outside GitHub?

Mostly. GitLab, Gitea and most static site builders make IDs the same way. Some older wikis differ, and a few renderers add a prefix. Click one link after you publish, and see the Markdown links guide.

Does Markdown support a table of contents?

No, Markdown has no table of contents syntax. What people call a TOC is just a bullet list of links pointing at heading anchors, which is why a generator can build one from your headings in a second. GitHub takes some of the pressure off by building a collapsible outline from the file header on its own. So a manual list earns its keep mainly in long documents and on every renderer that is not GitHub.

Keep learning

Try the Editor

Open Editor