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.
1. Loading the Permalink Configuration
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.
2. Building the Permalink Expander
Once the configuration is decoded, Hugo constructs a PermalinkExpander via NewPermalinkExpander. This object stores:
- A reference to the
urlizefunction (used to sanitize strings for URLs). - A cache of compiled patterns.
- A map of known permalink attributes—callbacks that extract data from a
Pageobject.
// 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:
- Escape handling – literal colons are escaped as
\:and replaced with a placeholder to avoid mis‑interpretation. - Token detection – a regex
: \w+ (\[.+?\])?identifies tags like:yearor:sections[2:4]. - Callback resolution – each token is mapped to its callback in
knownPermalinkAttributes. Slice syntax ([n],[n:m],[last]) is parsed bytoSliceFunc. - 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
4. Computing the Expanded Permalink for a Page
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
5. Building the Final URL from the Expanded Permalink
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 parsing –
DecodePermalinksConfigturns the TOML map into a structuredmap[kind][section]pattern. - Expander construction –
NewPermalinkExpanderregisters attribute callbacks for tokens like:year,:slug, and:sections[n]. - Pattern compilation –
getOrParsePatterncompiles regex matches into a closure that replaces tokens with page data, supporting slice syntax and escaped colons. - Per‑page expansion –
page__paths.gocallsExpand(section, page)to generate theExpandedPermalinkstring. - URL assembly –
CreateTargetPathsinpage_paths.goinjects the expanded value into the final relative and absolute URLs.
Frequently Asked Questions
How do I select only specific section levels in a permalink pattern?
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.
Can I include literal colons in my permalink pattern?
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.
How does Hugo prioritize permalink settings when multiple patterns exist?
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →