Our site’s build was pinned to Hugo 0.105.0, a release from October 2022. We moved it to 0.167.0, released in September 2026, in one pull request. The new version failed the build on one front matter key and printed three deprecation warnings. Those were the easy part. The two changes that would have hurt us made no noise at all: four of our main pages rendered without their content, and three category pages were about to move to new URLs.
The site uses the open-source Airspace theme as a git submodule, but most of its layouts are overridden in our own layouts/ folder. That matters here, because the template changes hit our overrides, not the theme.
How we found the breakage
We did it in three passes, before changing any code for real.
- A trial build. We built the current
mainbranch with 0.167.0 and wrote down everything it reported. That gave us one hard error, a list of deprecation warnings with the files that triggered them, and one behaviour change that affected URLs. We held the upgrade until a related change to our category pages had merged, because that change was what made those URLs matter. - Fix until there are no warnings. Our rule for this repository is that a build finishes with zero warnings. A deprecation today becomes a hard error in some later release, as the
_buildkey below shows. - Compare the two builds. We built the same tree with 0.105.0 and with 0.167.0 and compared the output: the list of files, the URLs in the sitemap, and the content of each page type.
The log only shows what Hugo chooses to report. The trial build caught the URL change because we looked at where the pages landed, not just at the messages. The empty pages turned up only in the third pass.
Break 1: _build is gone (hard error)
Two section index files use build options to stop Hugo rendering the theme’s tag and category list pages. They used the old key, _build. Hugo 0.145.0 deprecated it, and by 0.167.0 it was removed. The build stops with:
ERROR deprecated: The "_build" front matter key was deprecated in Hugo 0.145.0 and
subsequently removed. Use "build" instead.
The fix is the rename:
# Before
[_build]
render = "never"
list = "never"
# After
[build]
render = "never"
list = "never"
Hugo retires features in stages. On 0.145 through 0.147, a test build of ours logs the _build deprecation only at INFO level, which a normal build doesn’t print. Later releases raise it to a warning, then to an error. Jumping 62 minor versions at once, the first thing we saw was the error.
Break 2: .Site.Data and languageCode (warnings)
The rest of the trial build’s messages were deprecations. Each one names the version that deprecated it:
WARN deprecated: .Site.Data was deprecated in Hugo v0.156.0 ... Use hugo.Data instead.
WARN deprecated: .Site.LanguageCode was deprecated in Hugo v0.158.0 ... Use .Site.Language.Locale instead.
WARN deprecated: project config key languageCode was deprecated in Hugo v0.158.0 ... Use locale instead.
.Site.Data (and site.Data) became hugo.Data across 19 templates. The data files that drive our services, team and home page sections didn’t change, only how templates reach them:
{{/* Before */}}
{{ with .Site.Data.services }}
{{/* After */}}
{{ with hugo.Data.services }}
The language setting moved in both the configuration and every page template that set <html lang>:
# Before
languageCode = "en-us"
# After (0.158+)
locale = "en-US"
<html lang="{{ .Site.Language.Locale | default "en-us" }}">
After this, <html lang>, the inLanguage field in our structured data and the RSS <language> element all read en-US instead of en-us. Language tags are case-insensitive, so this is cosmetic, but it shows up in a diff and you should expect it.
Break 3: the layout that stopped applying (silent)
This is the one that would have done real damage. Our top-level pages (about, contact and two landing pages) are plain content files at the root of content/. Hugo gives them the type page. We rendered them with an override at layouts/page/single.html, which looked up a partial named by each page’s id front matter: the contact form and map for contact, the team for about, and so on.
Hugo 0.146.0 replaced the template system. In the new system, our layouts/page/single.html no longer matched those pages. Hugo fell back to layouts/_default/single.html, which in our tree was an old, unused blog-post layout. The result, with no error and no warning: /contact/, /about/, /app-stabilization/ and /ai-receptionist/ rendered with no form, no map, no team and no body.
We moved the dispatch into _default/single.html, replacing the dead layout, and deleted layouts/page/:
{{/* layouts/_default/single.html */}}
<main id="main" tabindex="-1">
{{ if isset .Params "id" }}
{{ partial .Params.id . }}
{{ else }}
{{ .Content }}
{{ end }}
</main>
Hugo’s overview of the new system lists the _default directory as removed and says to move its files up to the layouts/ root. Hugo 0.167 still maps the old location, so we kept the smaller change. If you have a type-named folder like layouts/page/, check it on any version from 0.146 on.
Break 4: category URLs with an ampersand (silent)
Three of our blog categories have an ampersand in the name, such as “App Stabilization & Rescue”. Each has a hub page with an intro, built from content/categories/app-stabilization-rescue/_index.md, at /categories/app-stabilization-rescue/. Those URLs were in our sitemap and linked from every post.
From Hugo 0.123.0, the term’s path is derived from the term itself, ampersand included. Under 0.167.0, our content folder no longer matched the term. The build produced two pages: the real term page at a new URL, /categories/app-stabilization--rescue/ (double hyphen) with no intro, and an orphan page at the old URL holding the intro and none of the posts.
A redirect from the old URL would have worked, but the old URL was the one we wanted. So we named each content folder after the term, ampersand and all, and pinned the public URL in front matter:
# content/categories/app-stabilization-&-rescue/_index.md
+++
url = "/categories/app-stabilization-rescue/"
title = "App Stabilization & Rescue"
+++
One template also had to change. Each post links to its category’s hub, and the old template built that path by running the category name through urlize. That guess no longer matched. It now finds the hub by title in the taxonomy:
{{ $hub := "" }}
{{ range $.Site.Taxonomies.categories }}
{{ if eq .Page.Title $category }}{{ $hub = .Page }}{{ end }}
{{ end }}
The same release changed how term titles are written on list pages. With no content file, a tag such as ci-cd was titled “Ci-Cd”. One configuration line keeps them as written:
capitalizeListTitles = false
Pinning the versions
The commit messages and Hugo’s error text gave us most versions. To check the two silent changes for this post, we rebuilt a minimal test site with 0.105.0, 0.122.0, 0.123.0, 0.145.0 and 0.146.0. The double-hyphen term page and the “Ci-Cd” title first appear in 0.123.0. The layouts/page/ override stops applying in 0.146.0.
Proving nothing else moved
With the warnings at zero, we compared a 0.105.0 build and a 0.167.0 build of the same tree.
- The file list was identical, apart from the fingerprinted stylesheet name. The minifier’s output had changed, so its hash did too.
- The sitemap URLs were identical, including all four category hubs.
- Every page type had its content: contact with its form and map, about with the team, the services pages, each landing page, the category hubs with their intros and posts, blog posts with their related-service link, and the case studies. The script that checks the contact form’s endpoint passed.
- The per-page differences were all expected:
lang="en-US", the casing of one compatibility meta tag, the stylesheet hash, a maps link that no longer HTML-encodes+, and code blocks rendered in the current version of the github-dark palette. Ourllms.txtwas byte-identical.llms-full.txtchanged for the better: it no longer included raw front matter for the top-level pages.
A comparison like this can be done with ordinary tools. This is one way to do it, not the exact commands from our pull request:
hugo-0.105.0 --minify -d /tmp/old
hugo-0.167.0 --minify -d /tmp/new
# Files that appeared or disappeared
diff <(cd /tmp/old && find . -type f | sort) <(cd /tmp/new && find . -type f | sort)
# URLs that appeared or disappeared from the sitemap
diff <(grep -o '<loc>[^<]*' /tmp/old/sitemap.xml | sort) \
<(grep -o '<loc>[^<]*' /tmp/new/sitemap.xml | sort)
The goal is a diff with nothing in it but the changes you can explain. When a URL shows up, fix the content so the old URL comes back, and run it again.
A word on warnings in CI. Our pipeline runs the same hugo --minify as a local build. The zero-warning rule is written down for everyone who works on the repository, but the pipeline doesn’t check it. If you want the pipeline to enforce it, Hugo has a flag for that. --panicOnWarning makes the first warning fail the build. We haven’t added it yet; it’s a sensible next step.
Checklist
- Read your pinned version’s age before planning. A jump of many releases skips the warnings that would have prepared you.
- Do a trial build first, and record every error and warning with the files that cause it.
- Fix every deprecation warning, not only the errors. Each one is a future build failure.
- Check any type-named layout folder, like
layouts/page/, on Hugo 0.146 or later. If a page falls back to another layout, nothing tells you. - Check taxonomy terms with
&or other punctuation on 0.123 or later. Name the content folder after the term and pinurlif you need to keep the old address. - Set
capitalizeListTitles = falseif your tags should keep their case. - Build the same commit with the old and new versions. Diff the file lists and the sitemap, and open one page of each type.
- Accept the upgrade only when every difference has an explanation.
- Consider
--panicOnWarningin CI so the next deprecation can’t slip in.
How we can help
We do upgrades like this on client build and deploy pipelines, with the before-and-after comparison as part of the work. See our Platform Reliability & FinOps services to find out how we can help.