Hugo Build Times for Large Repositories
Hugo is the fastest mainstream static site generator, but at tens of thousands of pages even Hugo's build time becomes something you have to manage deliberately. This guide covers diagnosing where the time goes, the image-processing and render-hook costs that dominate large repos, the caching that actually helps in CI, and the template patterns that cause super-linear blowups. It assumes the broader framework decision is settled — if not, start with Choosing the Right Static Site Generator for Production and weigh raw build speed against the other axes in the SSG Framework Selection Matrix.
This sits within the wider question of build performance at scale: where an Astro or Eleventy site leans on partial hydration and incremental rebuilds, Hugo leans on being fast enough to rebuild everything every time. That trade-off holds right up until template cost or image processing outgrows it — which is exactly the boundary this page is about.
A note on expectations up front: Hugo rebuilds the whole site on each hugo run — it has no incremental production build. (Only hugo server does fast in-memory partial rebuilds during local development.) So "speeding up builds" means making the full build cheaper and avoiding redundant work in CI, not skipping pages.
Diagnosing Where the Time Goes
Measure before optimizing. Hugo's built-in template metrics show where render time goes with no external tooling:
hugo --templateMetrics --templateMetricsHints
The report ranks templates by cumulative execution time and flags partials that are good caching candidates (via partialCached). Read three columns together: cumulative (total time spent in the template and everything it calls), average (per-invocation cost), and count (how many times it ran). A partial with a small average but a count in the hundreds of thousands is the one to cache; a partial with a large average and a count of one is the one to simplify. On the 10k-page documentation repo used for the numbers in this guide, the top three rows accounted for 71% of render time — two list partials and a single-page hook. For asset-heavy sites, add memory and path diagnostics to surface expensive image processing:
hugo --gc --minify --printMemoryUsage --printPathWarnings
--printMemoryUsage reveals whether the build is memory-bound — decoding thousands of source images into memory before re-encoding can push a large build past the RAM available on a small CI runner, at which point the operating system starts swapping and wall-clock time balloons for reasons that have nothing to do with template code.
For a stable wall-clock number that you can compare across commits, wrap the full build in hyperfine rather than eyeballing a single time run. hyperfine warms the cache, discards outliers, and reports a mean with standard deviation:
hyperfine --warmup 1 --runs 10 'hugo --gc --minify'
That command produced the 42.1s ± 1.3s cold-equivalent figure in the diagram. The same harness, fully expanded with a containerised environment and an identical synthetic corpus, is documented in How to Benchmark Hugo vs Astro Build Speeds.
Image Processing: Usually the Single Biggest Cost
On content sites with photographs or screenshots, Hugo's image pipeline (Resize, Fit, Fill, and .Process) is frequently the largest line item. Every distinct transform — each width, each target format — is an encode, and on a cold build with no cached resources that work runs for every image on every page.
The fix is twofold. First, emit fewer variants: generate only the widths your srcset actually uses, and pick one modern format rather than three. Second, let Hugo cache the encodes in resources/_gen so a warm build skips them entirely. A typical responsive shortcode that stays lean:
{{/* layouts/shortcodes/img.html */}}
{{ $src := .Page.Resources.GetMatch (.Get "src") }}
{{ $w := slice 480 960 1440 }}
{{ $variants := slice }}
{{ range $w }}
{{ $variants = $variants | append ($src.Resize (printf "%dx webp q80" .)) }}
{{ end }}
<img
src="{{ (index $variants 1).RelPermalink }}"
srcset="{{ range $i, $v := $variants }}{{ if $i }}, {{ end }}{{ $v.RelPermalink }} {{ $v.Width }}w{{ end }}"
sizes="(max-width: 960px) 100vw, 960px"
width="{{ (index $variants 2).Width }}" height="{{ (index $variants 2).Height }}"
loading="lazy" decoding="async" alt="{{ .Get "alt" }}">
Before and after restricting widths from five to three and dropping a redundant AVIF pass on this repo:
| Image config | Encodes per build | Cold build (hyperfine mean) |
|---|---|---|
| 5 widths × AVIF + WebP | ~46k | 42.1s |
| 3 widths × WebP only | ~17k | 31.8s |
That single change removed roughly 10 seconds before any caching. Reserving dimensions on every <img> also keeps Cumulative Layout Shift down — the asset side of that is covered in Optimizing WebP Images in Hugo Without Plugins.
Render Hooks and Caching
Markdown render hooks let you override how Hugo renders links, images, headings, and code blocks. They are powerful, but a hook fires once per matching element across the whole site — a link hook on a 10k-page repo with 30 links per page runs 300,000 times. Keep hook bodies trivial and avoid per-invocation work like .Site.GetPage lookups or resources.Get calls inside them.
The two highest-leverage moves are wrapping read-only partials in partialCached and keeping hook logic branch-light:
{{/* layouts/_default/_markup/render-link.html */}}
{{- $u := urls.Parse .Destination -}}
{{- $external := $u.IsAbs -}}
<a href="{{ .Destination | safeURL }}"{{ with .Title }} title="{{ . }}"{{ end }}
{{- if $external }} rel="noopener" target="_blank"{{ end }}>{{ .Text | safeHTML }}</a>
For partials whose output depends only on a stable key, give partialCached a cache key so it computes once:
{{ partialCached "nav.html" . .Section }}
Caching the navigation partial by .Section instead of re-rendering it on every page took render time on this repo from ~14s to ~9s. The full recipe — including the resource cache, render-hook discipline, and warming the cache in CI — lives in Speeding Up Hugo Builds with Render Hooks and Caching.
Template & Taxonomy Optimization
Template cost dominates large builds. The classic blowup is iterating .Site.RegularPages inside a partial that itself runs on every page — that is O(n²) and explodes on large repos. Scope iterations to .Pages, use .Site.GetPage for direct lookups, and wrap expensive read-only partials in partialCached.
If you do not use taxonomies or RSS, disable them so Hugo does not generate those pages. Note that disableKinds is a top-level config key (not under [params]):
# config.toml
disableKinds = ["taxonomy", "term", "RSS"]
[markup.goldmark.renderer]
unsafe = true
[markup.tableOfContents]
startLevel = 2
endLevel = 3
Disabling unused taxonomy and term pages on the benchmark repo removed about 4,000 generated pages and shaved ~3s on its own. Taxonomies are not free even when they are small: every term generates a list page and every list page renders a partial, so an over-eager tags front-matter convention can silently double your page count. Keep the taxonomies you actually navigate by and disable the rest.
Two more template habits pay off at scale. Prefer where and first to narrow a page collection before you range over it, rather than ranging over everything and testing inside the loop — the filter runs in Go, the loop body runs in the template engine, and the template engine is the slower of the two. And reach for partialCached with an explicit cache key any time a partial's output is a pure function of something stable (a section, a language, a menu). Porting heavy logic from another ecosystem — for instance the Jekyll Plugin Ecosystem — rarely translates one-to-one; prefer Hugo's native functions and shortcodes over re-implementing plugin behavior. If template complexity is the real constraint, reconsider the architecture against Astro vs Eleventy for Documentation Sites, or — for a React-based content site — Next.js Static Export for Content Sites, whose build model trades Hugo's raw speed for a component ecosystem.
What Actually Speeds Up CI
Since the build is always full, the win in CI is not recomputing image transforms and module downloads every run. The mental model is simple: keep everything Hugo derives from your sources — processed images, remote JSON, resolved modules — outside the container so the next run inherits it, and let Hugo re-render only the cheap parts.
resources/_gen on a matching cache key and skips straight to re-rendering (16.4s).Persist Hugo's resource cache and module cache between runs:
name: Build Hugo
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # needed if you use --enableGitInfo for lastmod
- uses: actions/cache@v4
with:
# Hugo's processed-resource and module caches.
path: |
resources/_gen
~/.cache/hugo_cache
key: ${{ runner.os }}-hugo-${{ hashFiles('assets/**', 'content/**/*.png', 'content/**/*.jpg') }}
restore-keys: |
${{ runner.os }}-hugo-
- run: hugo --gc --minify
resources/_gen holds already-processed images and other resources, so a cache hit skips re-encoding them — usually the single biggest CI saving on image-heavy sites. On the benchmark repo, a warm CI build with resources/_gen restored came in at 16.4s versus the cold 42.1s. --enableGitInfo is sometimes recommended here, but be clear about what it does: it attaches git commit metadata (useful for accurate lastmod dates), and does not make the build incremental.
The one subtlety worth getting right is the cache key. Key it on a hash of the inputs that affect the processed output — source images and the assets that produce them — so the cache invalidates precisely when it should. Key it too broadly (for example on the whole commit SHA) and you get a fresh miss on every push, re-encoding everything; key it too narrowly and a changed image slips through with a stale variant still cached. Hashing assets/** plus your content image globs, as above, tracks the actual encode inputs. The same delivery-side caching discipline — long-lived, content-hashed asset URLs behind a CDN — is what keeps those encodes cheap for readers, not just for CI.
| CI scenario | resources/_gen | Build (hyperfine mean) |
|---|---|---|
| Cold runner, no cache | empty | 42.1s |
| Warm runner, cache restored | hit | 16.4s |
Hugo Modules and Data Files
On large repos, two more line items quietly add up. The first is Hugo Modules: if your theme or shared components come in as modules, a cold build with no module cache re-downloads and re-resolves them. Vendoring with hugo mod vendor commits the modules into _vendor/ so the build never hits the network, and persisting ~/.cache/hugo_cache (shown in the CI step above) covers the non-vendored case. The second is data files and getJSON/getCSV remote calls: a resources.GetRemote or getJSON against a slow endpoint blocks the build, and on a large site that fans out across many pages. Fetch remote data once into site.Data rather than per page, and prefer committed local data files for anything that does not change between builds.
# config.toml — keep remote work out of the hot path
[caches]
[caches.getjson]
maxAge = "10m" # cache remote JSON between builds
[caches.images]
maxAge = "-1" # never expire processed images locally
Setting caches.images.maxAge = "-1" tells Hugo to keep processed images indefinitely, which is exactly what you want when you pair it with the resources/_gen cache in CI — the encode happens once and is reused across builds until the source image changes.
Measuring Over Time
Track total build duration and --templateMetrics across commits, and alert in CI when build time jumps more than ~10–15% — a sudden spike almost always traces to a new partial, an unscoped page iteration, or a freshly added batch of unprocessed images. Run the same hyperfine command on a fixed runner so the numbers are comparable; establish a repeatable baseline with How to Benchmark Hugo vs Astro Build Speeds.
Common Pitfalls
- Unscoped
.Site.RegularPagesin partials: iterating every page from within a per-page partial is O(n²) and explodes on large repos. Scope to.Pagesor use.Site.GetPage, and cache withpartialCached. - Heavy work inside render hooks: a hook runs once per matching element across the whole site. A
.Site.GetPageorresources.Getcall inside a link hook multiplies into hundreds of thousands of calls. Keep hook bodies trivial. - Over-generating image variants: five widths times two formats is ten encodes per image. Emit only the widths your
srcsetuses and one modern format unless you have measured a reason for more. - Assuming Hugo is incremental: it is not for production builds. Optimize the full build and cache
resources/_geninstead of expecting page-level skips. - No resource cache in CI: without persisting
resources/_gen, every run re-encodes every image. Cache it keyed on your assets and content.
A last note on when to stop optimising. Every site has a floor — the time it takes to read the content, render the Markdown and write the files — and once a build is within roughly 30% of that floor, further template work returns very little. Establish the floor early by timing a build with a trivial layout and no image processing; if the real build is 6 seconds against a 5-second floor, the remaining time is in the corpus rather than in your templates, and the next useful lever is CI caching rather than another round of profiling.
Record the floor and the current number in the repository, next to the build script, with the date. Six months later that one line is what tells you whether the build drifted or the site simply grew — a distinction nobody can make from memory.
Key Takeaways
- Profile first with
hugo --templateMetricsand ahyperfinewall-clock mean before changing anything. - Image processing is usually the biggest cost on content sites; cut variants and cache the encodes in
resources/_gen. - Render hooks run per element across the whole site — keep them trivial and lean on
partialCached. - Kill O(n²) page iterations and disable unused taxonomy/RSS kinds.
- In CI, restore
resources/_genand the module cache; a warm build halved (and more) the cold time on a 10k-page repo.
FAQ
What repository size can Hugo build efficiently?
Hugo comfortably handles 100k+ pages. Build time scales with template complexity and image processing far more than raw page count, so a well-scoped repo with cached resources and disabled taxonomies often builds in under a minute.
Does Hugo support incremental production builds?
No. The hugo command always rebuilds the whole site; only hugo server does fast in-memory partial rebuilds during development. Speed comes from cheaper full builds and CI caching, not from skipping pages.
Where does Hugo spend most of its build time on large repos?
On large content sites the time concentrates in three places: template rendering (especially unscoped page iterations), image processing through the Resize and Fill methods, and Markdown render hooks that run once per matching element. Template metrics show you which one dominates.
How do I catch Hugo build-time regressions in CI?
Log total build duration and template metrics on every commit and alert when build time jumps more than 10 to 15 percent. A sudden spike almost always traces to a new partial, an unscoped page iteration, or a newly added batch of unprocessed images.
Does caching resources/_gen make Hugo incremental?
No. Caching resources/_gen only avoids re-encoding images and other processed resources that have not changed. Hugo still re-renders every page on every build, but skipping image re-encoding is usually the single largest CI saving on media-heavy sites.
Related
- Parent: Choosing the Right Static Site Generator for Production — where Hugo's speed fits the framework decision.
- Profiling Hugo Templates With Template Metrics — find the partial eating the build.
- How to Benchmark Hugo vs Astro Build Speeds — the controlled harness behind these numbers.
- Speeding Up Hugo Builds with Render Hooks and Caching — the full caching and render-hook recipe.
- SSG Framework Selection Matrix — where build speed ranks against the other decision axes.
- Astro vs Eleventy for Documentation Sites — when template complexity argues for a different engine.
- Next.js Static Export for Content Sites — the React-based alternative when you need components over raw speed.
- Optimizing WebP Images in Hugo Without Plugins — the asset side of the biggest build-time cost.