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 -rorgit 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.
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.
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:
| Change | Build time | Template time for changed partials |
|---|---|---|
| Baseline | 58.1 s | 40.4 s |
| Footer + schema cached (no key) | 35.3 s | 18.0 s |
+ header cached by .Section | 23.9 s | 6.6 s |
| + sidebar split: list cached by subsection | 19.1 s | 1.8 s |
A 67% reduction in build time from four template lines and one split partial, with byte-identical output.
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
.RelPermalinkas a key caches per page, which saves nothing. - Relying on
cache potentialalone. It reflects the pages Hugo rendered during that run; a template change can alter it. Re-run metrics after edits. - Using
partialCachedfor side effects. Partials that call.Scratch.Setor.Store.Seton the page run only once when cached; move side effects out. - Rollback: replacing
partialCachedwithpartialrestores 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.
Related
- Parent: Hugo Build Times for Large Repositories — the wider build-speed picture.
- Profiling Hugo Templates with Template Metrics — reading the metrics in depth.
- Speeding Up Hugo Builds with Render Hooks and Caching — the other caching layers.
- Reducing Hugo Memory Usage on CI Runners — the memory side of large builds.
- Caching Hugo Builds in GitHub Actions — persisting caches between builds.