Upgrading Hugo After Seven Years: A Field Report
Migrating a Hugo site from v0.40 (2018) to v0.135 (2024) is not a trivial bump. The jump spans several major breaking changes, the most significant being a complete swap of the Markdown renderer. This is a practical account of what that migration involves, based on moving a blog with roughly 700 Markdown files.
Template and Data API Changes
The first set of issues comes from template lookup and data scope changes that landed in releases shortly after v0.40.
Partial template syntax. Since v0.42, the filesystem for project and theme files is virtualized. This means old-style lookups like {{ template "theme/partials/header.html" . }} no longer work. The replacement is the documented {{ partial "header.html" . }} form.
Homepage and RSS data scope. The .Data.Pages object has been replaced. In templates for the homepage and RSS feeds, this must now be site.RegularPages.
Next/previous post links. The meaning of .Next and .Prev was inverted in commit ad705aac064. Previously, "next" pointed to the older post in the past, and "prev" to the newer one in the future. Now, .Next points to the future post and .Prev to the past one, which is the intuitive behavior.
Finding these changes requires digging through old release notes. A useful trick is to script downloading all Hugo changelogs from the GitHub API into text files that can be grepped locally.
The Hard Part: Switching to Goldmark
The real pain point is the Markdown renderer. Blackfriday, the old default, was removed in v0.100.0. It had been deprecated for a long time, unmaintained, and had many known issues, with Goldmark positioned as a mature replacement.
The challenge is that the migration silently changes output. Nothing errors out; the HTML is just subtly different. The only way to catch problems is to diff the generated sites:
- Generate the site with the old version into
public_old. - Generate with the new version into
public. - Diff every HTML file between the two directories.
- Grep the diffs for red/green color codes (added/removed lines) to inspect every change manually.
There is a reason to persist through this: Goldmark implements the CommonMark standard, which should be more futureproof than Blackfriday's custom parsing. It also fixes real problems, including broken smart quotes and poor interactions between lists and blockquotes that were present but unnoticed in the old output.
Markdown Compatibility Rules
Most of the 80 files that needed fixing fell into a few repeatable categories.
Mixing HTML and Markdown. Inline Markdown inside HTML blocks no longer expands. A link like the following will not be parsed:
<small>
[a link](https://example.com)
</small>
The structure must be changed so the Markdown is outside the HTML tags:
<small>
[a link](https://example.com)
</small>
This alternative also works:
<small> [a link](https://example.com) </small>
Automatic character replacement. The sequence << is now converted to the guillemet character «. To preserve the literal characters, add this configuration:
markup:
goldmark:
extensions:
typographer:
leftAngleQuote: '<<'
rightAngleQuote: '>>'
Nested list indentation. A nested list that was previously rendered when indented by two spaces now requires four spaces. The indentation width depends on the size of the parent list marker, per the CommonMark spec.
1. a
* b
* c
2. b
Blockquotes inside lists. This now works correctly. A > quote within a list item will render as a blockquote, whereas it was previously ignored. The same applies to lists nested inside blockquotes. This surfaced several Markdown snippets that were silently broken under Blackfriday.
* something
> quote
* something else
Headings in lists. A # character at the start of a list item now renders as a heading. To display a literal #, it must be replaced with the HTML entity #.
* # passengers: 20
Line-leading list markers. A line cannot begin with a + or 1) without being interpreted as a list. For example, this Markdown:
`1 / (1
+ exp(-1)) = 0.73`
Previously rendered as a regular paragraph:
<p><code>1 / (1
+ exp(-1)) = 0.73</code></p>
Now it renders as a list:
<p>`1 / (1</p>
<ul>
<li>exp(-1)) = 0.73`</li>
</ul>
The same applies to an accidental 1) at the start of a line:
I set up a small Hadoop cluster (1 master, 2 workers, replication set to
1) on
This is a consequence of wrapping Markdown at 80 characters; the line wrapping is not context-sensitive with respect to list markers. The fix is to rewrap the source so the + or 1) is not the first character.
Improved smart quotes. Quotes inside code blocks are no longer replaced with typographic characters (e.g., ... becomes …) — a long-standing Blackfriday defect. Quote pairing in regular text is also more correct. For instance:
"Oh, *interesting*!"
- old: “Oh, interesting!“
- new: “Oh, interesting!”
Image and line break wrapping. Images are no longer wrapped in a <p> tag. A CSS adjustment, such as adding a margin-bottom to img elements, handles the visual spacing. Conversely, a <br> tag now gets wrapped in a <p> tag, which can add unintended vertical space; accepting this as a minor cosmetic issue is a pragmatic choice.
<img src="https://jvns.ca/images/rustboot1.png">
<br><br>
Configuration flags. To match previous behavior, the Hugo config also needs explicit settings. The relevant config.yaml additions disable code highlighting, use Blackfriday-style heading ID generation, and allow raw HTML in the Markdown source:
markup:
highlight:
codeFences: false
goldmark:
renderer:
unsafe: true
parser:
autoHeadingIDType: blackfriday
Theme Maintenance and Comparison Tools
Having a custom theme committed to the same repository as the site simplifies the migration—there is no upstream dependency to wait on. If a third-party theme were in use, copying it into the repo and maintaining it locally is the safer approach than waiting for an update.
There is also a small standalone program that compares Blackfriday and Goldmark output side by side for arbitrary Markdown snippets. While not configured exactly like the Hugo integration, it is useful for understanding specific parser behaviors in isolation.
On Backwards Compatibility
Hugo is not alone in this. For sites with a long lifespan, Jekyll and 11ty were cited as having good backwards compatibility, with some users reporting a decade of Jekyll use without issues. 11ty specifically lists stability as a core goal. The practical trade-off is the environment: Jekyll requires a working Ruby setup and 11ty a Node one, whereas Hugo remains a single static binary. Some Jekyll users avoid local builds entirely by using GitHub Pages.
Despite the hours spent, the upgrade is worthwhile. Hugo's speed and the static binary remain as valuable as they were in 2016. The old release archives are still available, and most breaking changes are old. The renderer swap is a one-time cost, aligned with a reasonable move toward CommonMark—even if it is not a task to undertake without a tolerance for tedious diffing.



