Tutorial1 day ago

3. Pushing Hugo to 50,000 Pages, and the Template Line That Cost 70 Seconds

Our 50,000 page Hugo build took 86 seconds until we moved one where filter into a cached partial, and then it took 16. We also fed it bad input on purpose: five of eight mistakes built with exit 0.

The WJS Desk

Sep 23, 2026 ยท 10 min read

Photo by Daniel Andraski on Pexels

By the end of this part you will know how Hugo behaves at 1,000, 10,000 and 50,000 pages, which one line of template took our 50,000 page build from 86 seconds to 16, and which kinds of bad input Hugo refuses and which it quietly publishes. You will also have a 13 line CI gate that caught five of the silent failures from parts 1 to 3.

Pages (articles)First templatesFixed templatesPeak memory (fixed)Output (fixed)
1,0000.25 to 0.49 s0.29 to 0.38 s240 MB14.2 MB
10,0003.96 to 4.11 s2.43 to 2.48 s1.55 to 1.67 GB125.8 MB
50,00086.84 to 92.64 s16.24 to 16.29 s5.0 to 5.2 GB615 MB

Wall-clock times from /usr/bin/time -l, clean builds with public/ deleted first, on an Apple M4 Pro with 48 GB of RAM running Hugo 0.166.0. Two or three runs per row. Peak memory is the maximum resident set size time reported.

How we built a 50,000 page site

We do not have 50,000 articles. We have the 128 from part 2, so we repeated them with a numeric suffix on each slug. That gives real article bodies, real HTML, real tag distributions (the "Open Source" tag gets 40 of every 128 articles) and real sizes, at any count we like. It is a synthetic corpus built from real content, and we will say so every time we quote a number from it.

The same script works on the two article sample from part 2, but a corpus of two repeated articles has two tags instead of 266, so your times will be lower than ours.

cd ~/hugo-course/wjs-adapter
cat > make-corpus.py <<'EOF'
import json, sys
n = int(sys.argv[1])
source = json.load(open("assets/articles.json"))
out = []
for i in range(n):
    a = dict(source[i % len(source)])
    a["slug"] = f"{a['slug']}-{i}"
    out.append(a)
json.dump(out, open(f"articles-{n}.json", "w"))
print(n, "articles")
EOF
python3 make-corpus.py 10000
cd ~/hugo-course
cp -R wjs-adapter scale-10000
cp wjs-adapter/articles-10000.json scale-10000/assets/articles.json
cd scale-10000
rm -rf public
/usr/bin/time -l hugo --quiet

Our 50,000 article JSON file was 504 MB. Hugo parsed it through the content adapter from part 2 without complaint.

What broke: 86 seconds, and not where we expected

With the part 2 templates, 1,000 pages took a quarter of a second and 10,000 took 4 seconds. Ten times the content, sixteen times the time. At 50,000 the build took 86.84 and 92.64 seconds. Five times the content of the 10,000 run, twenty-two times the time. Something was quadratic.

Our first guess was output size. The build wrote 816 MB, and two files stood out. The home page RSS feed was 29.7 MB with all 50,000 articles in it, because Hugo's default services.rss.limit is -1, meaning unlimited. And the tag pages from part 2 listed every tagged article on one page: /tags/open-source/ alone was 2.7 MB. We capped the feeds at 20 items and paginated the tag pages:

printf '\n[services.rss]\n  limit = 20\n' >> hugo.toml
cat > layouts/list.html <<'EOF'
{{ define "main" }}
<h1>{{ .Title }}</h1>
<ul>{{ range (.Paginate .Pages).Pages }}<li><a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a></li>{{ end }}</ul>
{{ partial "pagination.html" . }}
{{ end }}
EOF

Output dropped from 816 MB to 615 MB and the home feed from 29.7 MB to 10.9 KB. The build took 86.17 seconds. We had fixed the output and not the problem.

So we measured instead of guessing. Hugo has a built-in template profiler:

rm -rf public
hugo --templateMetrics --templateMetricsHints

At 10,000 pages it showed home.html spending 2.95 seconds of cumulative time across 500 renders, nearly as much as the 3.24 seconds for all 10,000 article pages. The culprit was the line we copied into part 2 without a second thought:

{{ range (.Paginate (where site.RegularPages "Section" "articles")).Pages }}

Hugo caches the paginator after the first page, but the where filter inside the parentheses is an argument, and arguments are evaluated on every render. With 20 articles per page, a 50,000 article site has 2,500 home pages, and each one filtered all 50,000 pages before throwing the result away. That is 125 million comparisons for a list that never changes. The fix is to compute the list once and cache it:

mkdir -p layouts/_partials
echo '{{ return where site.RegularPages "Section" "articles" }}' > layouts/_partials/articles.html
sed -i '' 's|(.Paginate (where site.RegularPages "Section" "articles")).Pages|(.Paginate (partialCached "articles.html" . "articles")).Pages|' layouts/home.html
rm -rf public
/usr/bin/time -l hugo --quiet

The 10,000 page build went from 4.0 to 2.45 seconds. The 50,000 page build went from 86 to 16.3. Same output, same filter, one line. Scaling is still not linear (five times the pages took 6.6 times as long), but it is no longer a cliff.

Pro tip: any where, sort or first over site.RegularPages in a template that renders many times (home, list, or a partial in every page) is a candidate for partialCached. Run --templateMetrics before you optimise anything. Our guess about output size was wrong and cost us a 90 second build to find out.

The sitemap Hugo writes and Google will not read

At 50,000 articles the sitemap had 50,274 URLs in one 9.3 MB file. The sitemaps protocol caps a single sitemap at 50,000 URLs, and search engines are entitled to ignore a file that goes over it. Hugo writes it without a warning. It splits sitemaps per language on multilingual sites, but a single-language site gets one file regardless of size. If you are anywhere near 50,000 pages, you need your own sitemap template or a sitemap index, and the CI gate below checks the count.

Live editing at 10,000 pages: the adapter's cost

Part 2 recommended the content adapter. Here is where it costs you. We ran hugo server on 10,000 pages built both ways and edited one article three times.

At 10,000 pagesMarkdown filesContent adapter
Clean build (3 runs)2.79 to 2.86 s2.43 to 2.48 s
Rebuild after editing one article393 to 420 ms2,402 to 2,949 ms
Rebuild after editing a template119 ms (adapter; fast render only redraws what the browser is viewing)
Server memory after edits1.6 GB4.3 GB

With one JSON file behind every page, changing one title means Hugo re-reads the whole file and treats every page as changed. With one file per article, it rebuilds what depends on that file. The converter also needs 1.84 seconds to write 10,000 files (111 MB). For a site you edit by hand, the file route wins. For a site regenerated from an export, where nobody live-edits, the adapter still wins.

Watch out: after an adapter data change, hugo server logs Rebuilt in 2402 ms. After a normal edit it logs Total in 393 ms. We grepped for "Total in", saw nothing for four minutes, took a goroutine dump, and nearly reported a hang. It was not a hang. Check the page, not the log wording.

What broke on purpose: feeding it bad input

We took the two article sample and broke it six ways, one at a time, then built.

Bad inputExitWhat Hugo did
Trailing comma in the JSON1Error naming the adapter file and transform.Unmarshal
Date as 02/09/20261unable to parse date, with line and column
Template typo, .Titel1Error with the template file, line 2, column 16
Date in 20270Article gone, and its "Migration" tag pages with it. 17 pages became 14
Two articles, same slug0One article gone. The URL served the second one's content
Missing title0Published with an empty <h1> and a title tag of " | My New Hugo Project"
null body0Published an empty article
Link to a page that does not exist0Published the broken link. Hugo has no link checker

The pattern is consistent. Anything that stops Hugo from parsing is loud and precise. Anything that parses but means the wrong thing is silent. The future date is the dangerous one: publish a post with a timezone slip or a typo in the year and it simply is not there, and neither is any tag page it was the only member of.

Two of those have flags. hugo list future prints every page held back by its date, and --buildFuture builds them anyway (17 pages again). For duplicate slugs, --printPathWarnings did warn when we reproduced it with two Markdown files:

WARN  Duplicate target paths: /articles/first-export/index.html (2)

It printed nothing for the same duplicate coming from a content adapter. If your pages come from data, de-duplicate slugs before Hugo sees them.

We pointed a link checker at the 128 article site from part 2. It found 30 broken internal targets. Our articles link to each other as /some-slug/, and Hugo had put them all under /articles/some-slug/. Every cross-link in the migrated site was a 404, and the build had been green since part 2.

The fix is a permalink rule, and the first one we tried made it worse:

printf '\n[permalinks.page]\n  articles = "/:slug/"\n' >> hugo.toml

That produced URLs like /1.-give-it-a-role-and-a-reason/. For adapter pages we had set path but not slug, so :slug fell back to the title. The token we wanted was :contentbasename, which uses the last element of the path:

sed -i '' 's|articles = "/:slug/"|articles = "/:contentbasename/"|' hugo.toml
rm -rf public
hugo --quiet

After that, the checker found 0 broken targets. Here it is. It reads every HTML file in public/, decodes entities (our first version reported /tags/c& as broken because the C++ tag is written c&#43;&#43;), and checks each root-relative link resolves to a file. It took 0.08 seconds on 409 pages.

cat > check-links.py <<'EOF'
import html, pathlib, re, sys
root = pathlib.Path("public")
broken = {}
for page in root.rglob("*.html"):
    for raw in re.findall(r'href="(/[^"]*)"', page.read_text()):
        href = re.split(r"[#?]", html.unescape(raw))[0]
        target = root / href.lstrip("/")
        if not (target.is_file() or (target / "index.html").is_file()):
            broken.setdefault(href, set()).add(str(page.relative_to(root)))
for href, pages in sorted(broken.items()):
    print(f"{href}  <- {len(pages)} page(s)")
print(f"{len(broken)} broken internal targets")
sys.exit(1 if broken else 0)
EOF
python3 check-links.py

A CI gate for everything the exit code misses

Here is the gate we ended up with. --panicOnWarning turns every Hugo warning into a failure, which on its own catches part 1's empty site (it exited 2) and part 2's stripped HTML (exit 1). The rest covers what Hugo does not warn about.

cat > ci-check.sh <<'EOF'
#!/bin/bash
set -euo pipefail
DOMAIN=$(hugo config | awk -F"'" '/^baseurl/ {print $2}')
rm -rf public
hugo --panicOnWarning --printPathWarnings --quiet
test -f public/index.html || { echo "FAIL: no index.html"; exit 1; }
if grep -q "example.org" public/sitemap.xml; then echo "FAIL: baseURL is still example.org"; exit 1; fi
FUTURE=$(hugo list future | tail -n +2 | wc -l | tr -d ' ')
[ "$FUTURE" = "0" ] || { echo "FAIL: $FUTURE future-dated pages will not publish"; hugo list future; exit 1; }
URLS=$(grep -o "<loc>" public/sitemap.xml | wc -l | tr -d ' ')
[ "$URLS" -le 50000 ] || { echo "FAIL: sitemap has $URLS URLs, the limit is 50000"; exit 1; }
python3 check-links.py
echo "OK: $URLS URLs in the sitemap for $DOMAIN"
EOF
chmod +x ci-check.sh
./ci-check.sh
sed -i '' "s|https://example.org/|https://your-real-domain.com/|" hugo.toml
./ci-check.sh

If you are following along from part 2, the first run fails with FAIL: baseURL is still example.org, because that project never set one. That is the gate working. The second run, after the sed, passes.

On the 128 article site it passed in 0.98 seconds and reported 402 sitemap URLs. We then injected three failures one at a time: an article dated 2027, a link to /nope/, and the default example.org base URL. It failed on each with exit 1 and a message that named the problem.

It does not catch everything, and we would rather say so than let you trust it. It does not catch a missing title, an empty body, a duplicate slug from an adapter, or part 2's rewritten code blocks. Those need checks against your own data before Hugo runs, because by the time Hugo sees them they are valid input.

Common mistakes

  • Putting a where filter inside .Paginate on a page that renders thousands of times.
  • Optimising output size before running --templateMetrics.
  • Leaving services.rss.limit at its unlimited default on a large site.
  • Trusting Hugo's sitemap past 50,000 URLs.
  • Using :slug in permalinks for adapter pages that only set path.
  • Assuming a green build means internal links resolve.
  • Reading the server log for one exact phrase instead of checking the page.

What we did not test

The large sites are our 128 articles repeated, not 50,000 distinct pieces, so tag counts scale up evenly in a way real archives do not. We did not test image processing, which is where real Hugo sites usually spend their build time, or multilingual sites, or Hugo's own build cache across runs (every build here started from an empty public/). All numbers are from one Apple M4 Pro with 48 GB of RAM. A CI runner with 7 GB would not survive our 50,000 page server session at 4.3 GB, and we did not try.

Next

Part 4 is the verdict: what three parts of measurements add up to, who should adopt Hugo in 2026, and who should keep what they have.

Share

Our 50,000 page Hugo build took 86 seconds. One cached where filter took it to 16. Then we fed it bad input: five of eight mistakes shipped with exit 0. #Hugo #WebPerf #StaticSite #DevOps

Never miss a ship

The best stuff that shipped this week, delivered every Thursday. Free, no spam. We read all the boring stuff so you get the fun parts.

Keep reading