Profiling Hugo Templates With Template Metrics
Hugo is fast enough that a slow Hugo build is almost always a specific template doing something expensive once per page. The problem is that "the build takes 41 seconds" gives you nowhere to start, and the usual instinct — reduce the number of pages — is both hard and unnecessary.
hugo --templateMetrics answers the question directly: which templates ran, how often, how long they took in total, and how many of those executions were served from cache. This guide reads that report, shows the three patterns it usually reveals, and measures the result on a 4,000-page site. It is part of Hugo Build Times for Large Repositories.
Prerequisites
- Hugo 0.120 or newer (
hugo version); the metrics output has been stable for several releases. - A build slow enough to care about — under ten seconds, this exercise is usually not worth it.
hyperfineortimefor measuring real build durations with instrumentation off.
Read the Report
hugo --templateMetrics --templateMetricsHints --quiet
Template Metrics:
cumulative average maximum cache percent cached total
duration duration duration potential count template
---------- --------- --------- --------- ------- ------ ----- --------
18.412s 4.60ms 31.2ms 92% 44.9 0 4001 partials/related-pages.html
9.884s 2.47ms 11.9ms 88% 24.1 0 4001 partials/sidebar-tree.html
4.201s 1.05ms 6.4ms 0% 10.2 0 4001 partials/head.html
2.930s 0.73ms 4.1ms 71% 7.1 0 4001 partials/breadcrumbs.html
1.612s 0.40ms 2.2ms 100% 3.9 0 4001 partials/footer-nav.html
Four columns decide what to do:
- cumulative duration — the only column that ranks work correctly. Sort by it and fix from the top.
- total count — a count equal to your page count means "runs once per page".
- cached — executions served by
partialCached. Zero here with a high count is the opportunity. - cache potential — Hugo's own hint about how much of this template's output looks identical across executions.
Pattern 1 — A Partial That Should Be Cached
The top row above is the archetype: a "related pages" partial that scans site pages to build a list. Its output varies by section, not by page, so it can be cached with the section as the key:
{{/* before — runs the scan once per page */}}
{{ partial "related-pages.html" . }}
{{/* after — one execution per section, reused for every page in it */}}
{{ partialCached "related-pages.html" . .Section }}
The variant key is the whole discipline here. partialCached "x" . with no key caches one result for the entire site, which is correct only when the output is genuinely global — a footer, a nav bar. Any per-section or per-type variation must appear in the key, or pages get another section's content, which is a much worse bug than a slow build.
Pattern 2 — A Scan That Should Be a Lookup
The second row, a sidebar tree, is usually a different problem: it iterates .Site.RegularPages per page to build a hierarchy. Caching helps, but the real fix is to compute the structure once and look it up:
{{/* Build the map once, in a cached partial that returns a dict */}}
{{ $tree := partialCached "build-tree.html" . "site-tree" }}
{{ $branch := index $tree .Section }}
A scan is O(pages) per page, which is O(pages²) per build — the reason a site that was fine at 400 pages becomes unbearable at 4,000. Turning the scan into one map build plus a lookup is what changes the shape of the curve rather than its constant.
Pattern 3 — Expensive Work Per Page That Nobody Needs
The third row, head.html at 0% cache potential, is genuinely per-page — it emits the title and canonical for each page. But partials like this often accumulate work that does not need to be there: a resource fingerprint recomputed per page, a date formatted three different ways, a where clause over all pages to decide whether to show a banner.
{{/* Fingerprint once for the whole site, not once per page */}}
{{ $css := resources.Get "css/main.css" | minify | fingerprint }} {{/* hoisted */}}
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}">
Hugo caches resource pipelines internally, so the second call is cheap — but the surrounding template logic is not, and moving invariant work out of a per-page partial is free speed.
Why Scans Dominate at Scale
The middle fix is the one worth understanding rather than copying, because it explains why build times get worse suddenly rather than gradually.
A template that iterates every page, executed once per page, does page-count squared units of work. At 400 pages that is 160,000 operations — invisible. At 4,000 pages it is 16 million, and the build that took four seconds now takes forty. Nothing changed except the corpus, which is why teams experience this as "Hugo got slow" rather than as a template they wrote three years ago.
The tell in the report is a partial whose cumulative duration grows faster than the page count between two profiles taken months apart. If you keep the metrics output from a release — even pasted into a commit message — that comparison takes seconds.
Verify With Real Timings
Instrumentation inflates absolute numbers, so measure the actual improvement with the flag off:
hyperfine --warmup 1 --runs 10 'hugo --gc --minify --cleanDestinationDir'
Re-run --templateMetrics after each change to re-rank; the second-worst template is frequently not what it was, because caching one partial removes work from another's measured time.
| Stage | Build time | Top template by cumulative |
|---|---|---|
| Baseline | 41.0 s | related-pages.html — 18.4 s |
+ partialCached on related pages | 23.6 s | sidebar-tree.html — 9.9 s |
| + tree scan → lookup | 9.1 s | head.html — 4.2 s |
| + hoisted invariants in head | 6.2 s | markdown rendering (not a template) |
Keep the Profile in the Repository
A profile taken once is a fix; a profile taken every release is a guard. Save the metrics output as a build artifact and print the top five rows in the CI log, so a template that starts creeping is visible in a pull request rather than in a complaint six months later.
hugo --templateMetrics --quiet 2>&1 | sed -n '/Template Metrics/,/^$/p' | head -12 \
| tee build-profile.txt
The cheapest useful assertion is a ceiling on the top row's cumulative duration. It is deliberately loose — the point is not to police milliseconds but to catch the change that adds a per-page scan, which shows up as a doubling rather than a few per cent. Pair it with the wall-clock budget from Measuring Build-Time Regressions in CI, and between them you know both that the build got slower and which template did it.
Pitfalls & Rollback
- Caching with the wrong key.
partialCachedwithout a variant key on per-section output serves the wrong section's content. Always pass the discriminator. - Optimising by average duration. The report sorts by cumulative for a reason; a 0.4 ms partial can be seconds of build.
- Trusting instrumented absolute times. They are inflated. Compare rows within a run and measure real times separately.
- Re-running the profile only once. After each fix the ranking changes; a second profile costs one build and prevents wasted work.
- Caching something that must vary. If in doubt, verify a handful of pages in the output rather than assuming — a wrong-content bug is silent.
- Rollback: every change here is one template line. Reverting a
partialCachedto apartialrestores previous behaviour immediately, with no cache to clear beyond the build directory.
Conclusion
A slow Hugo build has a name, and --templateMetrics tells you what it is in one run. Rank by cumulative duration, look for a high count with zero cached executions, and apply the three standard fixes — cache what does not vary, turn scans into lookups, and hoist invariant work out of per-page partials. Re-profile after each change, and confirm with real timings that instrumentation is not exaggerating. Combine this with the caching and hook strategies in Speeding Up Hugo Builds With Render Hooks and Caching.
FAQ
What does the cached column in template metrics mean?
It counts executions served from partialCached rather than re-rendered. A partial with a high count and zero cached entries is running once per page, which is the main thing the report exists to reveal.
Should I cache every partial?
No. partialCached is only correct when the output does not vary per page, or varies only by a key you can express. Caching a partial whose output depends on the current page produces wrong pages, which is far worse than a slow build.
Why is my average duration tiny but the build slow?
Because cumulative duration is what matters. A partial averaging 0.4 milliseconds across 4,000 pages is 1.6 seconds of build time; the report sorts by cumulative precisely so that small-but-frequent work is visible.
Does templateMetrics slow the build down?
Yes, measurably — instrumentation adds overhead, and the reported absolute times are inflated. Use it to compare templates against each other within one run, and measure real build times separately with the flag off.
What if the slow template is a shortcode?
Shortcodes appear in the report like any other template. A shortcode executed hundreds of times per build is a common cause, and the same caching and simplification advice applies.
Related
- Parent: Hugo Build Times for Large Repositories — the wider build-speed picture.
- Speeding Up Hugo Builds With Render Hooks and Caching — the caching layers beyond templates.
- How to Benchmark Hugo vs Astro Build Speeds — measuring builds honestly.
- Caching Hugo Builds in GitHub Actions — keeping the gains in CI.
- Measuring Build-Time Regressions in CI — stopping the 41 seconds from coming back.