How Hugo’s Permalink Patterns Are Defined and Resolved at Build Time

Hugo resolves permalink patterns at build time by parsing configuration into a PermalinkExpander, which substitutes tokens like :year, :slug, or :sections[2] with page-specific values to generate the final URL path.

Hugo’s permalink system allows you to define URL structures via configuration in hugo.toml (or config.toml). During the build, the gohugoio/hugo engine compiles these patterns into executable functions that extract attributes from each page—such as dates, titles, or section paths—and assemble the destination URL. This article traces the full resolution pipeline from configuration parsing to final path generation.


Hugo reads the permalinks map from site configuration into a generic map[string]any. The function DecodePermalinksConfig in resources/page/permalinks.go normalizes this into a structured map[string]map[string]string where the outer key is the content kind (pages, sections, taxonomies, terms) and the inner key is the section name.

// resources/page/permalinks.go
func DecodePermalinksConfig(m map[string]any) (map[string]map[string]string, error) {
    // …initialize per‑kind maps…
    // Handle entries like:
    //   [permalinks]          → default for pages & terms
    //   [permalinks.blog]     → per‑section overrides
    //   [permalinks.section]  → kind‑specific map
}

This ensures that a pattern defined under [permalinks.posts] applies only to the posts section, while a top‑level [permalinks] entry acts as a fallback.


Once the configuration is decoded, Hugo constructs a PermalinkExpander via NewPermalinkExpander. This object stores:

  • A reference to the urlize function (used to sanitize strings for URLs).
  • A cache of compiled patterns.
  • A map of known permalink attributes—callbacks that extract data from a Page object.
// resources/page/permalinks.go
func NewPermalinkExpander(urlize func(string) string,
    patterns map[string]map[string]string) (PermalinkExpander, error) {

    p := PermalinkExpander{
        urlize:       urlize,
        patternCache: hmaps.NewCache[string, func(Page) (string, error)](),
    }

    // Register the built‑in attribute callbacks (date, slug, etc.)
    p.knownPermalinkAttributes = map[string]pageToPermaAttribute{
        "year":    p.pageToPermalinkDate,
        "slug":    p.pageToPermalinkSlugElseTitle,
        // …
    }

    // Parse each kind‑specific pattern set
    for kind, patterns := range patterns {
        e, err := p.parse(patterns)
        if err != nil { return p, err }
        p.expanders[kind] = e
    }
    return p, nil
}

The knownPermalinkAttributes map is the heart of the system: it associates tokens like :year or :slug with Go functions that fetch the relevant value from the page.


3. Parsing a Pattern into an Executable Function

When Expand is called for a specific section, the expander looks up the raw pattern string (e.g., /:section/:year/:slug/) and compiles it via getOrParsePattern.

The compilation process:

  1. Escape handling – literal colons are escaped as \: and replaced with a placeholder to avoid mis‑interpretation.
  2. Token detection – a regex : \w+ (\[.+?\])? identifies tags like :year or :sections[2:4].
  3. Callback resolution – each token is mapped to its callback in knownPermalinkAttributes. Slice syntax ([n], [n:m], [last]) is parsed by toSliceFunc.
  4. Function generation – the final result is a closure that, given a Page, iterates over the callbacks and concatenates their outputs with the static parts of the pattern.
// resources/page/permalinks.go
func (l PermalinkExpander) getOrParsePattern(pattern string) (func(Page) (string, error), error) {
    // Normalise escape sequences
    pattern, normalized := l.normalizeEscapeSequencesIn(pattern)

    // Find all :tags
    matches := attributeRegexp.FindAllStringSubmatch(pattern, -1)
    // … build callbacks slice …
    // Return a closure that replaces each tag with its resolved value.
}

3.1 Built‑in Attribute Callbacks

The following tokens are available in any permalink pattern:

Token Callback Description
:year pageToPermalinkDate 4‑digit year from Page.Date
:month pageToPermalinkDate 2‑digit month
:day pageToPermalinkDate 2‑digit day
:title pageToPermalinkTitle URL‑safe version of Page.Title
:slug pageToPermalinkSlugElseTitle Page.Slug if defined, otherwise title
:sections pageToPermalinkSections Full section path (e.g., blog/2024)
:sectionslugs pageToPermalinkSectionSlugs Section slugs only
:sections[n] toSliceFunc Slice syntax to pick specific section levels
\: escape Literal colon character

Slice syntax allows fine‑grained control over deep section hierarchies. For example, :sections[2:4] selects the 2nd through 4th path segments, while :sections[last] grabs the deepest level.

Source: https://github.com/gohugoio/hugo/blob/master/resources/page/permalinks.go#L998-L1048


During the site build, Hugo’s page assembler invokes the expander. In hugolib/page__paths.go, the Expand method is called with the page’s section and the page itself:

// hugolib/page__paths.go (excerpt)
opath, err := d.ResourceSpec.Permalinks.Expand(p.Section(), p)
if err != nil { return desc, err }
if opath != "" {
    opath, _ = url.QueryUnescape(opath)
    opath = path.Clean(opath)
    desc.ExpandedPermalink = opath
}

The returned string (opath) is URL‑decoded and cleaned (removing redundant slashes or dots) before being stored in desc.ExpandedPermalink.

Source: https://github.com/gohugoio/hugo/blob/master/hugolib/page__paths.go#L90-L100


The ExpandedPermalink is consumed by CreateTargetPaths in resources/page/page_paths.go. This function assembles the final relative and absolute URLs for the page and its associated resources (e.g., index.html, page bundles).

// resources/page/page_paths.go (excerpt)
if d.Kind != kinds.KindHome && d.URL == "" && d.Section.Base() != "/" {
    if d.ExpandedPermalink != "" {
        pb.Add(d.ExpandedPermalink)   // <-- use the expanded pattern
    } else {
        pb.Add(d.Section.Base())
    }
    // …
}

If ExpandedPermalink is present, it overrides the default section‑based path, ensuring the custom pattern appears in both the relative link (tp.Link) and the absolute permalink (tp.Permalink).

Source: https://github.com/gohugoio/hugo/blob/master/resources/page/page_paths.go#L155-L170


6. Configuration Example

The following hugo.toml demonstrates a common setup:

[permalinks]
  # Default for all pages

  pages = "/:section/:year/:month/:slug/"
  
  # Override for the 'blog' section only

  [permalinks.blog]
    posts = "/:year/:month/:day/:title/"

Given a page with date: 2024-03-15, slug: "hugo-guide", and section blog, the final URL becomes:


https://example.org/2024/03/15/hugo-guide/

If the slug were omitted, the :slug token would fall back to the URL‑safe title.


Summary

  • Configuration parsingDecodePermalinksConfig turns the TOML map into a structured map[kind][section]pattern.
  • Expander constructionNewPermalinkExpander registers attribute callbacks for tokens like :year, :slug, and :sections[n].
  • Pattern compilationgetOrParsePattern compiles regex matches into a closure that replaces tokens with page data, supporting slice syntax and escaped colons.
  • Per‑page expansionpage__paths.go calls Expand(section, page) to generate the ExpandedPermalink string.
  • URL assemblyCreateTargetPaths in page_paths.go injects the expanded value into the final relative and absolute URLs.

Frequently Asked Questions

Use slice syntax inside the :sections token. For example, :sections[2] returns only the second path segment, while :sections[1:3] returns the first through third segments. The special index last grabs the deepest section level. This logic is handled by toSliceFunc in resources/page/permalinks.go.

What happens if a page lacks a slug but my pattern uses :slug?

The :slug token maps to pageToPermalinkSlugElseTitle, which checks Page.Slug. If the slug is empty, it falls back to the URL‑safe version of the page title. This ensures every page receives a valid path component even when no explicit slug is defined in front matter.

Yes. To prevent Hugo from interpreting a colon as a token prefix, escape it with a backslash: \:. During pattern parsing, normalizeEscapeSequencesIn temporarily replaces \: with a placeholder, performs token substitution, then restores the literal colon in the final string.

Hugo evaluates the permalinks map using a kind → section hierarchy. First, it looks for a pattern matching the page’s specific kind (pages, sections, taxonomies, or terms). Within that kind, it matches the exact section name (e.g., blog). If no section-specific rule exists, it falls back to the default pattern for that kind, or the global default if none is defined.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →