Hugo partialCached for Faster Builds

Hugo is fast, but a large site can still spend most of its build rendering the same thing thousands of times. A site header with a navigation menu, a footer with a sitemap of sections, a sidebar listing every page in a section — each is a partial called from the base template on every page, and on a 12,000-page site each call walks menus or page collections again. partialCached renders a partial once per build (or once per variant you specify) and reuses the output, which on the site measured here removed more than half the build time.

The catch is correctness. A cached partial whose output depends on the current page, but whose cache key does not include it, reuses the first page's output everywhere. This guide finds the expensive partials with template metrics, caches them with the right keys, and shows how to verify nothing went stale. It is part of Hugo Build Times for Large Repositories and follows the profiling approach in Profiling Hugo Templates with Template Metrics.

Prerequisites

  • Hugo extended 0.120 or newer.
  • A site large enough for template time to matter — a few thousand pages.
  • A way to diff two builds' output (diff -r or git diff --no-index).

Step 1: Measure Where Time Goes

hugo --gc --templateMetrics --templateMetricsHints > metrics.txt
head -20 metrics.txt
     cumulative       average       maximum      cache  percent  cached  total
       duration      duration      duration  potential   cached   count  count  template
     ----------      --------      --------  ---------  -------  ------  -----  --------
    18.412s        1.534ms      21.8ms          100        0       0  12004  partials/footer-sitemap.html
    11.907s          992µs      14.1ms          100        0       0  12004  partials/header-nav.html
     6.221s          519µs       9.3ms            0        0       0  12004  partials/sidebar.html
     4.884s          407µs       7.6ms          100        0       0  12004  partials/head/schema-org.html
     3.106s          259µs       4.4ms            0        0       0  12004  _default/single.html

The cache potential column is Hugo's estimate, from observing actual outputs, of how often the partial produced identical output. 100 means every call returned the same HTML — a perfect candidate. 0 means output varied every time. Here the footer sitemap and header nav were rendered 12,004 times each while producing identical HTML every time: 30 seconds of pure repetition.

Cumulative template time by partial Horizontal bars of cumulative render time. Footer sitemap 18.4 seconds with cache potential 100. Header navigation 11.9 seconds, potential 100. Sidebar 6.2 seconds, potential 0. Schema.org head partial 4.9 seconds, potential 100. Single page template 3.1 seconds, potential 0. Three partials with full potential account for 35 seconds. Template time on a 12,000-page build (seconds) footer-sitemap 18.4 · cacheable header-nav 11.9 · cacheable sidebar 6.2 · varies head/schema-org 4.9 · cacheable _default/single 3.1 · varies hugo --templateMetrics --templateMetricsHints; red = cache potential 100
Three partials that produced identical output on every page accounted for 35 seconds of a 58-second build.

Step 2: Cache the Invariant Partials

Partials whose output never varies need no key:

{{/* layouts/_default/baseof.html */}}
{{ partialCached "footer-sitemap.html" . }}
{{ partialCached "head/schema-org.html" . }}

The context (.) is still passed so the partial can read site data, but only the first call's context is used. That is exactly why the partial must not depend on the page.

Step 3: Cache Partials That Vary by a Few Values

The header navigation looked invariant in metrics because the active-menu highlight was done with client-side JavaScript. After moving the highlight into the template (so it works without JavaScript), its output varied by section. That is still cacheable — once per section rather than once per page — by passing the section as a variant key:

{{ partialCached "header-nav.html" . .Section }}

With 14 top-level sections, the partial now renders 14 times instead of 12,004. Variant keys can be any values that determine the output: section, language, a version for versioned docs. Multiple keys are allowed: {{ partialCached "nav.html" . .Section .Lang }}.

The sidebar, with cache potential 0, lists the pages in the current subsection with the current page highlighted. Caching it per subsection plus moving the highlight out of the cached part (render the list cached, then mark the active item with a tiny inline script, or with CSS using a per-page data-path attribute) reduced it from 6.2 s to 1.4 s, but that is a trade-off worth measuring rather than assuming.

Choosing the variant key Three rows. A footer with no page-dependent output uses no key and renders once. A header with an active section uses the section as key and renders 14 times. A sidebar with a per-page highlight either stays uncached at 12,004 renders or is split: the list is cached per subsection with 212 renders and the highlight is applied separately per page. The key must contain everything the output depends on depends on key renders per build footer-sitemap site only (none) 1 header-nav active section .Section 14 sidebar list subsection .CurrentSection.Path 212 sidebar highlight current page do not cache 12,004 (tiny)
Splitting a partial into a cacheable body and a small per-page part is often the real win.

Step 4: Prove Nothing Went Stale

The failure mode of partialCached is silent: the build succeeds and some pages show another page's content. Diff the output of the uncached and cached builds before merging:

git stash && hugo --gc -d /tmp/before && git stash pop
hugo --gc -d /tmp/after
diff -rq /tmp/before /tmp/after | head

An empty diff (apart from timestamps if your templates emit them) proves the cache keys are complete. The first attempt on this site produced 11,990 differing files: the header had been cached without a key while still containing aria-current on the active section. Adding .Section as the key brought the diff to zero. Keep the diff as a CI check whenever templates change, as part of the build gates in Measuring Build Time Regressions in CI.

Measured Impact

The 12,004-page site, hugo --gc --minify on a GitHub Actions runner, median of five runs:

ChangeBuild timeTemplate time for changed partials
Baseline58.1 s40.4 s
Footer + schema cached (no key)35.3 s18.0 s
+ header cached by .Section23.9 s6.6 s
+ sidebar split: list cached by subsection19.1 s1.8 s

A 67% reduction in build time from four template lines and one split partial, with byte-identical output.

Build time after each caching step Four bars of total build time: baseline 58.1 seconds, after caching footer and schema 35.3 seconds, after caching the header by section 23.9 seconds, and after splitting the sidebar 19.1 seconds. Build seconds, cumulative changes 58.1 35.3 23.9 19.1 baseline footer + schema + header by section + sidebar split hugo --gc --minify, 12,004 pages, median of 5 on GitHub Actions ubuntu-latest
The first two lines of change bought the most; the sidebar split was worth doing but required the most care.

Beyond partialCached

Template caching removes repeated rendering. Three other kinds of repetition often remain in large Hugo builds and respond to different tools. Repeated page queries: a partial that calls where site.RegularPages "Section" "guides" on every page walks all pages each time; storing the result once in site.Store from a partial called early in the base template, or computing it in a cached partial that returns a slice with return, avoids the walk. Repeated resource processing: resources.Get | fingerprint | minify on the same stylesheet in every page is cached by Hugo already, but image processing with changing parameters per page is not — standardise sizes so the resources cache in resources/_gen can do its job, as described in Optimizing WebP Images in Hugo Without Plugins. Repeated remote data: resources.GetRemote calls cache to disk and can persist across builds when the cache directory is restored in CI. Template metrics show the first; --logLevel info output and build timings show the other two.

Multilingual and Multi-Output Sites

Two Hugo features change what a correct key looks like. On a multilingual site, nearly every navigation partial differs by language — labels, links and order all change — so .Lang (or site.Language.Lang) belongs in the key of any cached partial that renders text or URLs. Forgetting it produces English navigation on German pages, which the output diff catches immediately because thousands of files change. On sites with multiple output formats (HTML plus AMP, or HTML plus a JSON search output), a partial called from both formats may need the output format in its key too; .OutputFormats.Get or a separate partial per format keeps them apart. On this site's German and Japanese variants, adding .Lang to three keys raised render counts from 14 to 42 for the header — still a rounding error against 36,000 page renders across all languages.

Pitfalls & Rollback

  • Caching page-dependent output without a key. The most common bug; always diff before and after.
  • Keys that are too fine. Passing .RelPermalink as a key caches per page, which saves nothing.
  • Relying on cache potential alone. It reflects the pages Hugo rendered during that run; a template change can alter it. Re-run metrics after edits.
  • Using partialCached for side effects. Partials that call .Scratch.Set or .Store.Set on the page run only once when cached; move side effects out.
  • Rollback: replacing partialCached with partial restores per-page rendering exactly; the change is local to each call.

Conclusion

On large Hugo sites, a handful of partials rendered on every page often dominate the build. Template metrics with hints identify them, partialCached with the right variant keys renders them once per distinct output, and a before/after output diff proves correctness. On a 12,000-page site that took the build from 58 to 19 seconds without changing a single byte of output.

FAQ

What does partialCached do?

It renders a partial once per unique combination of the partial name and any variant keys you pass, and reuses the output for every later call in the same build. A partial called on 12,000 pages with no variant renders once instead of 12,000 times.

When is partialCached unsafe?

When the partial's output depends on something that differs between calls but is not part of the cache key, such as the current page. The cached output from the first page is then reused on every page, producing wrong active states, titles or links.

How do I find which partials to cache?

Run hugo --templateMetrics --templateMetricsHints. It lists every template with its cumulative time and call count, and the hints column estimates how cacheable each partial is based on whether its output varied between calls.

Does partialCached persist between builds?

No. The cache lives for a single build. For work that should persist across builds, such as processed images or remote data, Hugo's resources cache and file cache are the right tools.