The Same Heading Gets a Different Link in Every App
A heading's link is not stored in your note. Each app computes it from the heading text, with its own rules — so ## 2026 Goals links as #2026-goals under GitHub's rules and as #goals in pandoc's default Markdown reader. Only plain-word headings that start with a letter and appear once got the same link in every renderer we tested.
The question is an old one. Three Stack Overflow questions about linking to part of a Markdown document — the oldest asked in May 2010 — had about 2.18 million views between them when we read the counts on 2026-10-27.1 The task is ordinary: write [see the goals](#goals) in a note, and expect the click to land on the goals.
It lands while the note stays in the app that wrote it. Then the note is pushed to GitHub, converted with pandoc, built into a site, or opened somewhere else — and the heading quietly gets a new id.
An earlier piece here on navigating a long note by its outline ended on a one-line caveat: the heading travels, the slug does not. This is the measurement behind that line.
On 2026-10-27 we ran 20 real headings from 12 popular GitHub files, plus 12 headings typed the way people write notes, through GitHub's own renderer, the github-slugger emulator, pandoc's markdown and gfm readers, Python-Markdown's table-of-contents extension and markdown-it-anchor. Obsidian, which links by heading text rather than by slug, is covered from its documentation.
Why does a link to a Markdown heading break in another app?
Because the link points at an id the app invents. [see goals](#goals) asks the browser to find an element whose id is goals. The renderer computes that id from the heading text when it builds the page, and each renderer computes it differently. Move the note to another app, and the id changes while your link stays the same.
The browser's side of this is fixed by the HTML Standard. When a page is opened at a fragment, the browser looks for a match: "If there is an element in the document tree whose root is document and that has an ID equal to fragment, then return the first such element in tree order."2
Nothing in the Markdown file records that id. The file holds ## 2026 Goals and nothing else. The id exists only in the HTML, and it is recomputed every time a tool renders the file.
heading in the file the renderer's rule id in the page your link (#2026-goals)
## 2026 Goals -> GitHub-style rules -> id="2026-goals" -> found, page scrolls
## 2026 Goals -> pandoc -f markdown -> id="goals" -> not found, nothing happens
The failure is silent. When no element matches, there is nothing to scroll to, so the page simply stays where it is. There is no error and no warning. The link just stops doing anything.
That is why the problem survives so long in a set of notes. Nobody clicks every internal link after an export.
The measured table: 20 real GitHub headings, five engines
GitHub's renderer and its closest emulator, github-slugger, agreed on 19 of 20 real headings. pandoc's gfm reader matched GitHub on 15, its default markdown reader on 9, markdown-it-anchor on 7 after decoding, and Python-Markdown's default on 5. Only three headings — C, Index and Git — got the same id in every engine.
The headings are not invented. They come from 12 public files: five language lists in EbookFoundation's free-programming-books, and the READMEs of nvm, tldr, wtfjs, public-apis, screenshot-to-code, nodebestpractices and emoji-cheat-sheet. We picked headings that cover every character class that splits ids — digits, punctuation, emoji, accents, non-Latin scripts and repeats. The repositories are doing nothing wrong; they are simply where real headings live.
The GitHub column is GitHub's own output. We fetched the HTML GitHub serves for each file and read the id on every heading, with GitHub's user-content- prefix removed. We re-read the same ids from the github.com pages on the writing day, and all 20 were unchanged.3
| Heading (source) | GitHub, rendered | pandoc markdown | pandoc gfm | Python-Markdown default | markdown-it-anchor, decoded |
|---|---|---|---|---|---|
C / C# / C++ (free-programming-books) | c / c-1 / c-2 | c / c-1 / c-2 | c / c-1 / c-2 | c / c_1 / c_2 | c / c# / c++ |
0 - Méta-listes (free-programming-books, fr) | 0---méta-listes | méta-listes | 0---méta-listes | 0-meta-listes | 0---méta-listes |
Bash / Shell (free-programming-books, fr) | bash--shell | bash-shell | bash--shell | bash-shell | bash-/-shell |
目录 (free-programming-books, zh) | 目录 | 目录 | 目录 | _1 | 目录 |
Smileys & Emotion (emoji-cheat-sheet) | smileys--emotion | smileys-emotion | smileys--emotion | smileys-emotion | smileys-&-emotion |
🛠 Getting Started (screenshot-to-code) | -getting-started | getting-started | -getting-started | getting-started | 🛠-getting-started |
🙋♂️ FAQs (screenshot-to-code) | -faqs after two invisible characters | faqs | raising_hand_man-faqs | faqs | 🙋♂️-faqs |
io.js (nvm) | iojs | io.js | iojs | iojs | io.js |
Alpine Linux 3.5 - 3.12 (nvm) | alpine-linux-35---312 | alpine-linux-3.5---3.12 | alpine-linux-35---312 | alpine-linux-35-312 | alpine-linux-3.5---3.12 |
What does "tldr" mean? (tldr) | what-does-tldr-mean | what-does-tldr-mean | what-does-tldr-mean | what-does-tldr-mean | what-does-"tldr"-mean? |
💡 Explanation: twice (wtfjs) | -explanation / -explanation-1 | explanation / explanation-1 | bulb-explanation / bulb-explanation-1 | explanation / explanation_1 | 💡-explanation: / 💡-explanation:-1 |
🎊 2026 edition is here! (nodebestpractices) | -2026-edition-is-here | edition-is-here | confetti_ball-2026-edition-is-here | 2026-edition-is-here | 🎊-2026-edition-is-here! |
APILayer Unified Suite in now Live! 🎉 🥳 (public-apis) | apilayer-unified-suite-in-now-live-- | apilayer-unified-suite-in-now-live | apilayer-unified-suite-in-now-live-tada-partying_face | apilayer-unified-suite-in-now-live | apilayer-unified-suite-in-now-live!-🎉-🥳 |
Архитектура приложений (ru) / Περιεχόμενα (el) | архитектура-приложений / περιεχόμενα | same as GitHub | same as GitHub | _2 / _3 | same as GitHub |
Index, Git | index, git | same | same | same | same |
Ids identical to GitHub's, out of 20 headings: github-slugger 19, pandoc gfm 15, pandoc markdown 9, Python-Markdown with slugify_unicode 8, markdown-it-anchor 7 after percent-decoding (3 as written), Python-Markdown default 5. Identical in all five engines: 3 — C, Index, Git.3
The table also carries a quieter result. For ordinary English headings, most engines agree most of the time. The splits concentrate in a short list: leading digits, punctuation, emoji, letters outside ASCII under one default, and headings that repeat.
Why each renderer makes a different link
Each engine follows a short, documented rule, and the rules disagree on five points: leading digits, punctuation, emoji, letters outside ASCII, and repeated headings. Once you have read the rules, almost every split in the table is predictable. None of it is random, and none of it is a bug — each engine does what its documentation says.
GitHub: remove punctuation, one hyphen per space
GitHub publishes its rule as a list. Its documentation introduces it this way: "If you need to determine the anchor for a heading in a file you are editing, you can use the following basic rules:"4 Letters are lowercased. "Spaces are replaced by hyphens (-)." "Any other whitespace or punctuation characters are removed." A repeated anchor gets "a unique identifier," "generated by appending a hyphen and an auto-incrementing integer."4
Two of the table's odd-looking ids follow directly. Bash / Shell has a space, a slash and a space; the slash is removed and each space becomes a hyphen, so the id is bash--shell. Smileys & Emotion becomes smileys--emotion the same way.
Emoji are removed too, but the space after them is not. That is why 🛠 Getting Started starts with a hyphen: -getting-started.
One emoji did something no rule describes. 🙋♂️ FAQs is built from several code points joined by an invisible zero-width joiner. GitHub's id kept two of the invisible characters — U+200D and U+FE0F — in front of -faqs.3 The link works if copied from the page. Typed by hand from what you see, it will not.
A small caution about GitHub's own worked example. The rules say letters are lowercased, and GitHub rendered Περιεχόμενα as περιεχόμενα. The documentation's sample link for a heading about the Greek letter Θ, though, ends in an uppercase …-greek-letter-Θ.4 We did not test whether that link scrolls on github.com. The practical lesson is simpler: copy the anchor from the rendered page rather than deriving it by hand.
github-slugger: the emulator, right 19 times in 20
github-slugger is a JavaScript library built to predict GitHub's ids. Its README sets the goal plainly: "The overall goal of this package is to emulate the way GitHub handles generating markdown heading anchors as close as possible."5
It meets that goal almost entirely. Its only miss in our 20 was the joined emoji above — it dropped the zero-width joiner that GitHub kept. For every other heading, its id was GitHub's id, character for character.
pandoc: two readers, two rules
pandoc's default markdown reader removes everything before the first letter. The manual states the step directly: "Remove everything up to the first letter (identifiers may not begin with a number or punctuation mark)."6 Its own example turns 3. Applications into applications.
That one rule explains the starkest splits in our runs. 2026 Goals became goals. 0 - Méta-listes became méta-listes. 🎊 2026 edition is here! became edition-is-here — the emoji and the year both gone.
The same reader keeps periods. Its rule is to "Remove all non-alphanumeric characters, except underscores, hyphens, and periods,"6 so io.js stays io.js, where GitHub writes iojs.
pandoc's gfm reader is closer to GitHub. The manual describes its identifier extension as changing "the algorithm used by auto_identifiers to conform to GitHub's method," and adds: "Emojis are replaced by their names."7 That last sentence is the whole gap. GitHub drops 💡; pandoc gfm writes bulb-explanation. pandoc gfm matched GitHub on 15 of 20 headings, and every miss was an emoji heading.
Same program, two answers — decided by an input flag.
Python-Markdown: ASCII first, then numbers
Python-Markdown's table-of-contents extension folds every heading to ASCII by default. A comment in its source says so: "Replace Extended Latin characters with ASCII, i.e. žlutý => zluty."8 So Café notes becomes cafe-notes, and 0 - Méta-listes becomes 0-meta-listes.
Letters with no ASCII form disappear entirely. 目录, Архитектура приложений and Περιεχόμενα each fold to an empty string. The extension then makes the empty id unique the way its docstring describes: "Ensure id is unique in set of ids. Append '_1', '_2'... if not."8
The result is a positional id. Run together in one test file, the three headings became _1, _2 and _3 in document order. Add one non-Latin heading near the top, and every later one renumbers — along with every link that pointed at it.3
This is a default, not a fault. The documentation offers the alternative in one line: "An alternate version of the default callable supporting Unicode strings is also provided as markdown.extensions.toc.slugify_unicode."9 With it, 目录 stays 目录.
markdown-it-anchor: lowercase, hyphenate, encode
markdown-it-anchor, the heading-id plugin for the markdown-it parser, has a one-line default:
const slugify = (s) => encodeURIComponent(String(s).trim().toLowerCase().replace(/\s+/g, '-'))
It lowercases, turns whitespace into hyphens and percent-encodes what is left.10 It removes no punctuation at all. Bash / Shell becomes bash-%2F-shell, which decodes to bash-/-shell.
Percent-encoding on its own is harmless. The HTML Standard has the browser try the fragment as written and then, if that fails, the decoded form — "Let fragmentBytes be the result of percent-decoding fragment."2 So caf%C3%A9-notes and café-notes reach the same heading.
That is why the table compares markdown-it-anchor after decoding: 7 of 20 matched GitHub decoded, 3 of 20 as written. What still splits is the kept punctuation. Q&A: What's Next? decodes to q&a:-what's-next?; the other engines write qa-whats-next.
Obsidian: no slug at all
Obsidian links by heading text, not by a computed id. Its help page: "To link to a heading in another note, add a hash (#) at the end of the link destination, followed by the heading text." For the Markdown link format, it adds: "When using the Markdown format, make sure to URL encode the link destination."11
So an Obsidian heading link carries the heading's words, and the app finds the heading that matches them. We did not run Obsidian, and we make no claim about what its links become once the note is rendered by something else. Our piece on why wikilinks aren't plain Markdown covers that boundary.
Twelve note-style headings: the same split, predicted
Headings people type in notes split in the same places. Of 12 test headings, three — Notes the first time, Meeting notes and Project plan — got one id everywhere. The GitHub column here is github-slugger's prediction, not a GitHub render. The emulator matched real GitHub on 19 of 20 headings: a strong stand-in, not a perfect one.
The real-GitHub table proves the rules on headings someone else wrote. This one shows the same rules on headings you might type tomorrow.
| Heading in the note | github-slugger (predicts GitHub) | pandoc markdown | pandoc gfm | Python-Markdown default | markdown-it-anchor, decoded |
|---|---|---|---|---|---|
Q&A: What's Next? | qa-whats-next | qa-whats-next | qa-whats-next | qa-whats-next | q&a:-what's-next? |
2026 Goals | 2026-goals | goals | 2026-goals | 2026-goals | 2026-goals |
Café notes | café-notes | café-notes | café-notes | cafe-notes | café-notes |
会议 记录 | 会议-记录 | 会议-记录 | 会议-记录 | _1 | 会议-记录 |
C++ / Rust tips | c--rust-tips | c-rust-tips | c--rust-tips | c-rust-tips | c++-/-rust-tips |
Ideas 💡 | ideas- | ideas | ideas-bulb | ideas | ideas-💡 |
Notes (first) | notes | notes | notes | notes | notes |
Notes (second) | notes-1 | notes-1 | notes-1 | notes_1 | notes-1 |
foo_bar-baz.qux | foo_bar-bazqux | foo_bar-baz.qux | foo_bar-bazqux | foo_bar-bazqux | foo_bar-baz.qux |
Spaces inside (three spaces) | spaces---inside | spaces-inside | spaces-inside | spaces-inside | spaces-inside |
Meeting notes | meeting-notes | meeting-notes | meeting-notes | meeting-notes | meeting-notes |
Project plan | project-plan | project-plan | project-plan | project-plan | project-plan |
Three of 12 headings are identical in every column.3 The Spaces inside row needs a flag: github-slugger keeps one hyphen per space, but we found no real GitHub file with repeated spaces inside a heading, so that cell is a prediction we could not confirm.
The 2026 Goals row is the one most likely to bite. A note with a goals section per year links cleanly on GitHub and in pandoc's gfm mode. Convert the same note with pandoc's default reader, and every year-led heading loses its year — so 2026 Goals and 2025 Goals both reach for goals, and the second becomes goals-1.
Reproduce it: the test note and three short scripts
Every cell above can be rerun in minutes. The test note is the twelve headings from the last table. A Node script prints github-slugger and markdown-it-anchor ids, a Python script prints Python-Markdown's, and a shell loop prints pandoc's. Versions on 2026-10-27: github-slugger 2.0.0, markdown-it 15.0.3, markdown-it-anchor 10.0.0, Python-Markdown 3.11.2, pandoc 3.13.
The test note, saved as note.md (the tenth heading has three spaces between its words):
## Q&A: What's Next?
## 2026 Goals
## Café notes
## 会议 记录
## C++ / Rust tips
## Ideas 💡
## Notes
## Notes
## foo_bar-baz.qux
## Spaces inside
## Meeting notes
## Project plan
The JavaScript side. github-slugger remembers earlier slugs, so it numbers repeats the way GitHub does:
// compare.mjs: npm i github-slugger markdown-it markdown-it-anchor
import fs from 'node:fs';
import GithubSlugger from 'github-slugger';
import MarkdownIt from 'markdown-it';
import anchor from 'markdown-it-anchor';
const note = fs.readFileSync(process.argv[2], 'utf8');
const html = new MarkdownIt().use(anchor).render(note);
const ids = [...html.matchAll(/<h\d id="([^"]*)"/g)].map((m) => m[1]);
const headings = [...note.matchAll(/^#{1,6} +(.+)$/gm)].map((m) => m[1]);
const slugger = new GithubSlugger();
headings.forEach((h, i) => {
console.log([h, slugger.slug(h), ids[i], decodeURIComponent(ids[i])].join(' | '));
});
Run node compare.mjs note.md. Two of its lines:
Café notes | café-notes | caf%C3%A9-notes | café-notes
C++ / Rust tips | c--rust-tips | c%2B%2B-%2F-rust-tips | c++-/-rust-tips
The Python side prints both slug functions, so the _1 and its fix sit next to each other:
# toc_ids.py: pip install markdown
import re, sys, markdown
from markdown.extensions.toc import slugify_unicode
note = open(sys.argv[1], encoding="utf-8").read()
def ids(**config):
html = markdown.markdown(note, extensions=["toc"],
extension_configs={"toc": config})
return re.findall(r'<h\d id="([^"]*)"', html)
print("default: ", ids())
print("slugify_unicode:", ids(slugify=slugify_unicode))
And pandoc, once per reader:
for reader in markdown gfm; do
echo "== $reader"
pandoc -f "$reader" -t html note.md | grep -oE '<h[1-6] id="[^"]*"'
done
The pandoc loop is where the 2026 Goals split shows up first: id="goals" under markdown, id="2026-goals" under gfm.3
Duplicates: why C, C# and C++ become c, c-1 and c-2
A heading's link can depend on the headings above it. GitHub removes # and +, so C, C# and C++ all start as c, and its duplicate rule numbers the repeats c-1 and c-2. Insert or reorder a heading with the same text, and every later link shifts. GitHub's own documentation warns about exactly that.
The warning is specific: "If you edit a heading, or if you change the order of headings with "identical" anchors, you will also need to update any links to those headings as the anchors will change."4
free-programming-books shows the effect in a real file. Its list of programming languages has headings for C, C# and C++, and GitHub renders them as c, c-1 and c-2.3 A link to #c-2 reaches C++ only while nothing named C, or anything that slugs to c, is added above it.
The engines also disagree on the suffix itself. GitHub, pandoc and markdown-it-anchor append -1. Python-Markdown appends _1. markdown-it-anchor never collides here at all, because it keeps the # and + and percent-encodes them.
The file's maintainers solved it by hand. Next to the headings they wrote explicit anchors:
### <a id="csharp"></a>C\#
### <a id="cpp"></a>C++
GitHub kept both, as user-content-csharp and user-content-cpp, alongside the automatic c-1 and c-2.12 The file's history shows the anchors switched from <a name=…> to <a id=…> in a February 2022 commit titled as making them "HTML5 anchors compilant."12
So a link to #cpp works no matter how the headings are ordered. That is the escape hatch. It does not hold everywhere.
Explicit ids: {#id} is not portable, <a id> mostly is
Setting the id by hand works only where the syntax is understood. ## 2026 Goals {#goals-2026} set the id in pandoc's markdown reader and in Python-Markdown with attr_list, and was printed as part of the heading elsewhere. An HTML anchor, <a id="goals-2026"></a>, was kept by GitHub, pandoc and Python-Markdown, but printed as text by markdown-it's default.
The {#id} form comes from pandoc's heading attributes. The manual: "Headings can be assigned attributes using this syntax at the end of the line containing the heading text:"13 Our Markdown cheat sheet lists it as the extended-syntax way to name a heading. The table below shows where it stops working.
| Explicit id, tested 2026-10-27 | pandoc markdown | pandoc gfm | Python-Markdown default | Python-Markdown + attr_list | markdown-it default | markdown-it html: true | GitHub |
|---|---|---|---|---|---|---|---|
## 2026 Goals {#goals-2026} | id set | printed in heading | printed in heading | id set | printed in heading | printed in heading | not documented |
### <a id="cpp"></a>C++ | kept | kept | kept | kept | printed as text | kept | kept |
Where {#goals-2026} is not understood, it does more than fail. It becomes part of the visible heading — 2026 Goals {#goals-2026} — and, in pandoc's gfm reader and Python-Markdown's default, part of the automatic id: 2026-goals-goals-2026.3
GitHub's documentation does not describe {#id} on headings. It documents HTML anchors instead: "You can use standard HTML anchor tags" — its example uses the name attribute — and it notes that "Custom anchors will not be included in the document outline/Table of Contents."4 We did not render {#id} on GitHub itself, so we only report what GitHub documents.
The HTML anchor has one clear gap. markdown-it ships with raw HTML switched off, so its default prints <a id="cpp"></a> on the page as literal text. With html: true the anchor is kept. Our piece on the CommonMark edge cases that break editors first showed that default escaping a <div>; an anchor tag gets the same treatment.
So the more portable choice for a GitHub-first note is <a id="…"></a> on the heading line. It is not universal. Check how the place you publish handles raw HTML.
How to write a heading whose link survives the move
Start the heading with a letter, use plain words without symbols or emoji, and do not repeat it in the note. Headings like that matched in every engine we ran. When a heading must keep a number or a symbol, add an <a id> anchor and link to that — or link by quoting the text instead of the heading.
Five rules, each tied to a result above:
- Start with a letter. pandoc's default reader drops everything before the first letter, so
2026 Goalsbecamegoals.Goals for 2026keeps its year in every engine. - Plain words only.
& / + # . : ! ? " 'and emoji are where GitHub, pandoc and markdown-it-anchor part ways.Bash and Shellsurvives;Bash / Shelldoes not. - Unique within the note. A repeated heading takes a number, and the number depends on order.
C,C#andC++becamec,c-1,c-2. - Know your one exception for non-Latin and accented text. GitHub, both pandoc readers and markdown-it-anchor kept
目录,MétaandΠεριεχόμεναintact. Python-Markdown's default did not. If you publish through it, setslugify_unicode. - Pin what you cannot rename. Put
<a id="short-name"></a>on the heading line and link to#short-name— except where raw HTML is switched off.
A short script flags the headings in a note that break those rules. It reads one file and prints what it finds:
# check_headings.py: flag headings whose links are likely to differ between apps
import re, sys, unicodedata
seen = {}
for n, line in enumerate(open(sys.argv[1], encoding="utf-8"), 1):
m = re.match(r"#{1,6} +(.+?)\s*#*\s*$", line)
if not m:
continue
text, problems = m.group(1), []
if not text[0].isalpha():
problems.append("does not start with a letter")
symbols = sorted({c for c in text if not (c.isalnum() or c in " _-")})
if symbols:
problems.append("symbols or emoji: " + " ".join(symbols))
if " " in text:
problems.append("repeated spaces")
if any(unicodedata.decomposition(c) for c in text if not c.isascii()):
problems.append("accents (Python-Markdown's default drops them)")
if any(c.isalpha() and not c.isascii() and not unicodedata.decomposition(c)
for c in text):
problems.append("non-Latin letters (Python-Markdown's default empties them)")
key = text.lower()
if key in seen:
problems.append(f"repeats line {seen[key]}")
seen.setdefault(key, n)
if problems:
print(f"line {n}: {text!r}: " + "; ".join(problems))
On the test note it flags nine of the twelve headings. It passes Notes the first time, Meeting notes and Project plan — the three that matched everywhere.3
Two habits complete the rule. When you need a link to someone else's heading, copy the anchor from the rendered page rather than deriving it from the rules — GitHub's own example disagrees with its renderer on case. And when the heading is not yours to change, link by the words instead: our piece on linking to a specific sentence, not the whole note covers quoting text in the link, which skips the slug entirely.
Trade-offs and what this test does not show
This is one day's measurement of named versions and 32 headings, not a permanent law. Repeated spaces on GitHub, {#id} on GitHub and Obsidian's own exports were not measured. And a broken heading link fails quietly — the browser simply does not scroll, so nothing tells you it broke.
Obsidian was documented, not run. Its row is what its help page says. We make no claim about what Obsidian writes when it exports a heading link in Markdown format.
Two GitHub cells are unconfirmed. github-slugger predicts spaces---inside for a heading with repeated spaces, and we found no real file to check it against. GitHub's handling of {#id} is reported as undocumented, not as tested.
Versions drift. Every row names a version. pandoc 3.13 reproduced every cell of an earlier run on pandoc 3.6.4, which is evidence of a stable rule — not a promise.
Sites configure these engines. markdown-it-anchor accepts a custom slugify, Python-Markdown accepts slugify_unicode, and pandoc changes rules with its reader. A site built on any of them may not use the default we measured.
pandoc's section links have a limit of their own. The manual notes that "this method of providing links to sections works only in HTML, LaTeX, and ConTeXt formats."6 An internal link exported to another format may not be a link at all.
The problem is older than Markdown. In 1998, Hugh Davis wrote a paper "concerned with broken hypertext links," and concluded "that there can be no universal solution to this problem."14 His subject was links in general, not slug rules. The shape is the same: a link that points at something computed elsewhere breaks when that computation changes.
Frequently Asked Questions
How do I link to part of the same document in Markdown?
Write a normal link whose destination is # plus the heading's id: [see the goals](#goals). The id is computed from the heading text by the renderer — on GitHub, lowercase letters, spaces as hyphens, punctuation removed. Because each renderer computes it differently, copy the id from the rendered page instead of guessing it.4
How do I add links to sections of a README on GitHub?
Link to # plus the heading's anchor, e.g. [Install](#install). GitHub lowercases the heading, replaces each space with a hyphen, removes other punctuation and drops emoji, then numbers repeats -1, -2. For an exact value, open the rendered README and copy the anchor from the heading's link icon.43
Why does my Markdown heading link work on GitHub but not after converting with pandoc?
pandoc's default markdown reader uses a different rule from GitHub. It removes everything before the first letter, so 2026 Goals becomes goals, and keeps periods, so io.js stays io.js. Convert with -f gfm to get GitHub-style ids, except for emoji, which pandoc names and GitHub drops.67
How do I link to two headings with the same name in different sections?
The second gets a number: on GitHub and in pandoc notes and notes-1, in Python-Markdown notes and notes_1. That number changes if headings are reordered. For a stable target, put an HTML anchor on the heading line — ### <a id="setup-linux"></a>Setup — and link to #setup-linux.412
How do I handle symbols like &, / or ? in a GitHub heading anchor?
Leave them out of the link. GitHub removes punctuation and turns each space into a hyphen, so Smileys & Emotion links as #smileys--emotion — two hyphens, because both spaces remain. Bash / Shell becomes #bash--shell. A question mark or colon at the end simply disappears.43
What is the most portable way to make a custom anchor in Markdown?
An HTML anchor on the heading line, <a id="goals-2026"></a>. In our test it was kept by GitHub, pandoc and Python-Markdown. The {#goals-2026} attribute worked only in pandoc's markdown reader and Python-Markdown with attr_list. markdown-it's default prints raw HTML as text, so check there first.133
A heading is text you wrote. Its link is a guess each app makes about that text.
Write the heading so every guess comes out the same.
MNMNOTE keeps your notes as open Markdown on your own device, so the heading you wrote is the same text any other app will read. mnmnote.com
References
Footnotes
-
Stack Overflow, "How to link to part of the same document in Markdown?" (question 2822089, asked 2010-05-12), "How to link to a named anchor in Multimarkdown?" (question 6695439) and "Cross-reference (named anchor) in markdown" (question 5319754); view counts read via the Stack Exchange API on 2026-10-27. https://stackoverflow.com/questions/2822089 ↩
-
WHATWG, HTML Living Standard, §7.4.6.4 "Scrolling to a fragment." Accessed 2026-10-27. https://html.spec.whatwg.org/multipage/browsing-the-web.html ↩ ↩2
-
MNMNOTE, heading-id test run, 2026-10-27: github-slugger 2.0.0, markdown-it 15.0.3 with markdown-it-anchor 10.0.0, Python-Markdown 3.11.2 (
tocextension), pandoc 3.13 (markdown,gfmandcommonmark_xreaders). GitHub ids were read from the HTML GitHub serves for 12 public files and re-read from the github.com pages the same day. The test note and scripts are reproduced in this post. https://github.com/jgm/pandoc/releases/tag/3.13 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 -
GitHub Docs, "Basic writing and formatting syntax," sections "Section links" and "Custom anchors." Accessed 2026-10-27. https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9
-
Flet, github-slugger, README (version 2.0.0). Accessed 2026-10-27. https://github.com/Flet/github-slugger ↩
-
MacFarlane, J., Pandoc User's Guide, "Extension:
auto_identifiers." Accessed 2026-10-27. https://pandoc.org/MANUAL.html#extension-auto_identifiers ↩ ↩2 ↩3 ↩4 -
MacFarlane, J., Pandoc User's Guide, "Extension:
gfm_auto_identifiers." Accessed 2026-10-27. https://pandoc.org/MANUAL.html#extension-gfm_auto_identifiers ↩ ↩2 -
Python-Markdown,
markdown/extensions/toc.py, tag 3.11.2. Accessed 2026-10-27. https://github.com/Python-Markdown/markdown/blob/3.11.2/markdown/extensions/toc.py ↩ ↩2 -
Python-Markdown documentation, "Table of Contents." Accessed 2026-10-27. https://python-markdown.github.io/extensions/toc/ ↩
-
Galliat, V., markdown-it-anchor,
index.js(defaultslugify). Accessed 2026-10-27. https://github.com/valeriangalliat/markdown-it-anchor ↩ -
Obsidian Help, "Internal links," section "Link to a heading in a note." Accessed 2026-10-27. https://help.obsidian.md/links ↩
-
EbookFoundation, free-programming-books,
books/free-programming-books-langs.md, and commit 0e9ad7e (#6730), 2022-02-25. Accessed 2026-10-27. https://github.com/EbookFoundation/free-programming-books/blob/main/books/free-programming-books-langs.md ↩ ↩2 ↩3 -
MacFarlane, J., Pandoc User's Guide, "Extension:
header_attributes." Accessed 2026-10-27. https://pandoc.org/MANUAL.html#extension-header_attributes ↩ ↩2 -
Davis, H. C., "Referential integrity of links in open hypermedia systems," Proceedings of the Ninth ACM Conference on Hypertext and Hypermedia (Hypertext '98), pp. 207–216, 1998, doi:10.1145/276627.276650. https://doi.org/10.1145/276627.276650 ↩