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.
  • hyperfine or time for 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.
How to read the template metrics columns A breakdown of a metrics row. Cumulative duration of 18.4 seconds ranks the template first. Average duration of 4.6 milliseconds looks harmless on its own. Total count of 4,001 equals the page count, meaning it runs once per page. Cache potential of 92 percent and cached count of zero together identify the opportunity. One row, four signals cumulative 18.412s rank by this average 4.60ms looks harmless total count 4,001 = one per page cache potential 92% · 0 cached the opportunity Reading: this partial renders once per page, 92% of that output never varies, and none of it is cached Expected saving from partialCached: roughly 0.92 × 18.4 s ≈ 17 s Absolute times are inflated by instrumentation — compare rows within a run, not across runs Measure the real improvement afterwards with the flag off
Average duration is the number people quote and the least useful one. A partial averaging under five milliseconds was 45% of this build.

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.

Three fixes and what each is worth Three patterns with their savings on a 4,000-page build. Caching the related-pages partial saves 17 seconds. Replacing the sidebar scan with a lookup saves 9 seconds. Hoisting invariant work out of the head partial saves 3 seconds. Total build time falls from 41 seconds to 6 seconds. 41 s → 6 s, three changes, no content removed partialCached related-pages −17 s scan → lookup sidebar-tree −9 s hoist invariants head.html −3 s Shared linear scale · 4,000 pages, hyperfine 10 runs, warm module cache, metrics off Remaining 6 s is markdown rendering and file I/O — the floor for this corpus
The ordering matters: fixing the top row first meant the second measurement was taken on a build where it was no longer the dominant cost, which is how you avoid optimising something that stopped mattering.

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.

Per-page scan versus one-time map build as a site grows Two curves against page count. The per-page scan grows quadratically: 1.2 seconds at 500 pages, 5 seconds at 1,000, 20 seconds at 2,000 and 41 seconds at 4,000. The map-plus-lookup approach grows linearly and stays under 7 seconds across the same range. The curve, not the constant 45 s 0 s per-page scan · 41 s map + lookup · 6 s 500 1,000 2,000 4,000 pages Same templates, same machine · the only variable is how many pages the corpus contains
A site that doubles its content and quadruples its build time has a quadratic template somewhere. The metrics report finds it in one run.

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.

StageBuild timeTop template by cumulative
Baseline41.0 srelated-pages.html — 18.4 s
+ partialCached on related pages23.6 ssidebar-tree.html — 9.9 s
+ tree scan → lookup9.1 shead.html — 4.2 s
+ hoisted invariants in head6.2 smarkdown 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. partialCached without 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 partialCached to a partial restores 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.