gohugoio/hugo · error

circular call stack detected in partial %q

Error message

circular call stack detected in partial %q

What it means

Raised by `partialCached` (tpl/partials/partials.go:223) when the partial being invoked, with the same cache-variant key, is already on the current template call stack. Because `partialCached` uses a get-or-create cache keyed by name+variants, a self-recursive cached call would block waiting on its own in-flight cache entry, so Hugo detects the cycle by walking the parent template chain and fails instead of deadlocking.

Source

Thrown at tpl/partials/partials.go:223

func (ns *Namespace) IncludeCached(ctx context.Context, name string, context any, variants ...any) (any, error) {
	start := time.Now()
	key := partialCacheKey{
		Name:     name,
		Variants: variants,
	}
	keyString := key.Key()

	depsManagerIn := tpl.Context.GetDependencyManagerInCurrentScope(ctx)
	ti, err := ns.lookup(name)
	if err != nil {
		return nil, err
	}

	if parent := tpl.Context.CurrentTemplate.Get(ctx); parent != nil {
		for parent != nil {
			if parent.CurrentTemplateInfoOps == ti && parent.Key == keyString {
				// This will deadlock if we continue.
				return nil, fmt.Errorf("circular call stack detected in partial %q", ti.Filename())
			}
			parent = parent.Parent
		}
	}

	r, found, err := ns.cachedPartials.cache.GetOrCreate(keyString, func(string) (includeResult, error) {
		var depsManagerShared identity.Manager
		if ns.deps.Conf.Watching() {
			// We need to create a shared dependency manager to pass downwards
			// and add those same dependencies to any cached invocation of this partial.
			depsManagerShared = identity.NewManager()
			ctx = tpl.Context.DependencyManagerScopedProvider.Set(ctx, depsManagerShared.(identity.DependencyManagerScopedProvider))
		}
		// Mark the ctx so templates.Defer can reject being called from a cached body.
		ctx = tpl.Context.IsInPartialCached.Set(ctx, true)
		r := ns.doInclude(ctx, keyString, ti, context)
		if ns.deps.Conf.Watching() {
			r.mangager = depsManagerShared

View on GitHub ↗ (pinned to 8a468df065)

Solutions

  1. Use plain `partial` instead of `partialCached` for recursive partials.
  2. If caching is needed, pass a variant argument that differs at each recursion level (e.g. the section path or depth): `{{ partialCached "menu.html" . .Depth }}`.
  3. Break the cycle by splitting the partial: a cached outer wrapper that calls an uncached recursive inner partial.

Example fix

<!-- before: menu.html -->
{{ partialCached "menu.html" .Child }}
<!-- after -->
{{ partial "menu.html" .Child }}
Defensive patterns

Strategy: validation

Validate before calling

{{/* break recursion with an explicit depth counter passed in context */}}
{{ $depth := default 0 .depth }}
{{ if lt $depth 10 }}
  {{ partial "tree.html" (dict "node" .node "depth" (add $depth 1)) }}
{{ end }}

Prevention

When it happens

Trigger: A partial calling itself via `partialCached` (directly or through intermediaries) with identical variant arguments, e.g. `layouts/_partials/menu.html` containing `{{ partialCached "menu.html" . }}` for nested menu levels without a distinguishing variant.

Common situations: Recursive menu/tree rendering converted from `partial` to `partialCached` for performance; two partials that cachedly include each other; recursion where the variant key doesn't change per level so every recursion maps to the same cache entry.

Related errors


AI-assisted analysis of gohugoio/hugo@8a468df065 (2026-07-31). Data as JSON: /data/errors/d0284b89f20b579e.json. Report an issue: GitHub ↗.