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.

Where time goes in a large Hugo build, before and after tuning A pipeline showing content parsing, template rendering, image processing, and render hooks, each labelled with a share of a 42-second cold build, alongside an after-tuning total of 16 seconds measured with hyperfine. A 10k-page Hugo build: where the seconds go Parse content frontmatter + Markdown ~6s Render templates & partials ~14s Process images Resize / Fill encode WebP ~16s Render hooks links / images per element ~6s Before tuning cold full build, hyperfine mean 42.1s After tuning 16.4s
On a 10k-page repo, image processing and template rendering dominate the cold build; caching processed resources and scoping iterations cut a 42.1s build to 16.4s (hyperfine mean of 10 runs).

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 configEncodes per buildCold build (hyperfine mean)
5 widths × AVIF + WebP~46k42.1s
3 widths × WebP only~17k31.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.

Cold versus warm CI runner: what the resource cache skips Two CI pipeline lanes. A cold runner checks out the repo, finds resources/_gen empty, re-encodes all 46k image variants, then renders and minifies for a 42.1 second build. A warm runner checks out, restores the cache, hits resources/_gen and skips the encodes, then re-renders only for a 16.4 second build. A shared cache key hashed from assets and content images feeds both lanes, producing a miss on the cold lane and a hit on the warm lane. Cold vs warm CI: what the resource cache skips Cold runner — no cache restored Checkout actions/checkout resources/_gen EMPTY cache miss Re-encode all images ~46k variants Render + minify 42.1s cold Cache key = hash(inputs) assets/** + content/**/*.png|jpg miss hit Warm runner — resources/_gen restored Checkout actions/checkout Cache restore actions/cache key hit _gen HIT skip encodes ~0 re-encoded Re-render only 16.4s warm
The same full build runs both times — the resource cache only removes the encode step. A cold runner re-encodes every image variant (42.1s); a warm runner restores 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 scenarioresources/_genBuild (hyperfine mean)
Cold runner, no cacheempty42.1s
Warm runner, cache restoredhit16.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.

The order to attack a slow Hugo build Four ordered layers: profile with template metrics first, then cache image processing, then cache repeated partials, then pin a stable cache directory in CI. Each layer is cheaper than the one below it and usually recovers more time. The order to attack a slow Hugo build 1 · Profile with --templateMetrics find the partial with the largest cumulative duration 2 · Cache image processing a stable cacheDir turns re-encodes into cache hits 3 · partialCached the repeat offenders one execution per section instead of one per page 4 · Persist the cache in CI so the runner starts warm rather than cold Skipping straight to step 4 is the common mistake — a cached build of slow templates is still a slow build.
Working top down means each measurement is taken on a build where the previous cause has already been removed.

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.RegularPages in partials: iterating every page from within a per-page partial is O(n²) and explodes on large repos. Scope to .Pages or use .Site.GetPage, and cache with partialCached.
  • Heavy work inside render hooks: a hook runs once per matching element across the whole site. A .Site.GetPage or resources.Get call 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 srcset uses 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/_gen instead 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 --templateMetrics and a hyperfine wall-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/_gen and 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.