How Hugo Manages Module Dependencies Using Go Modules
Hugo treats every theme and component as a Hugo Module—a Go module enhanced with mounts, vendoring, and replacement handling—and builds the dependency graph by recursively parsing go.mod files using Go's standard tooling.
When you run hugo mod graph, Hugo is not reinventing package management. Instead, the gohugoio/hugo repository implements a thin wrapper around Go Modules that adds Hugo-specific functionality while preserving full compatibility with the Go toolchain. This architecture allows Hugo to resolve theme dependencies, handle local replacements, and vendor assets using the same go.mod files that standard Go projects use.
What Is a Hugo Module?
A Hugo Module is a regular Go module defined by a go.mod file, but Hugo enriches it with additional metadata. According to the source code in modules/module.go, each module implements the Module interface, which exposes methods like Path(), Version(), Replace(), and Vendor() to track the module's identity, replacement paths, and vendoring status. This abstraction allows Hugo to treat themes, shortcodes, and asset libraries as interchangeable units in a dependency tree.
Core Data Structures
The module system rests on four primary source files that handle configuration, representation, collection, and client operations.
modules/module.go
This file defines the Module interface and the moduleAdapter struct that wraps Go modules for Hugo's consumption. The interface requires implementations to expose the module path, semantic version, replacement module (if any), and whether the module is vendored. This is the foundation of how Hugo manage dependencies at the structural level.
modules/config.go
Hugo-specific configuration—such as proxy settings, replacement directives, and workspace mode—is decoded here into the Config struct. This allows you to specify module settings in your config.yaml or config.toml that Go itself does not natively understand, such as mounting specific directories from a theme into your project.
modules/collect.go
The collector struct in this file performs the heavy lifting of walking the module tree. It records seen modules, handles circular dependencies, and constructs the final ModulesConfig. A critical step occurs in filterDuplicateMounts, which ensures that the first module defining a given mount point wins, enforcing Hugo's "first-module-wins" rule.
modules/client.go
This is the public API surface. The Client struct provides NewClient, Graph, Vendor, and Tidy methods. It bootstraps the collector, executes the Go binary when downloading is required, and writes the dependency graph to an io.Writer.
The Dependency Resolution Flow
Hugo builds the dependency graph through a six-stage pipeline that bridges Hugo configuration with Go module semantics.
1. Client Initialization
When you invoke a module command, NewClient checks for the presence of a go.mod file in the working directory using afero.Exists(fs, n). If found, the path is stored in Client.GoModulesFilename, enabling Go-based operations. The client also detects the Go binary status to provide helpful error messages if Go is not installed.
2. Tree Collection
The Client.collect method instantiates a collector and calls c.collect(). This process reads the project's go.mod and parses require directives using golang.org/x/mod/module. For each requirement, Hugo creates a moduleAdapter. It also applies replacement directives from the site configuration (Config.Replacements), allowing local development overrides.
3. Mount Deduplication
After constructing the tree, ModulesConfig.finalize calls filterDuplicateMounts(m.mounts) to remove duplicate mount definitions. This guarantees deterministic behavior where the first module in the dependency list controls shared mount points.
4. Vendoring Support
The Client.Vendor method creates a _vendor directory (distinct from Go's standard vendor folder) and copies module sources into it. It skips modules where Vendor() returns true to avoid nesting vendor directories. A modules.txt manifest is written to track the vendored versions, mirroring go mod vendor functionality but adapted for Hugo's asset structure.
5. Graph Output
Running hugo mod graph invokes Client.Graph, which iterates over mc.AllModules and prints lines in the format <parent-module> <child-module>. The method resolves replacement directives, displaying local directory paths when replacements are active rather than semantic versions.
6. Tidying Dependencies
Client.Tidy runs a fresh collection without vendoring (c.collect(false)) and then invokes c.tidy to prune unused entries from go.mod and go.sum. This ensures the dependency graph remains minimal and accurate.
Interacting with the Go Toolchain
Hugo does not reimplement Go's module resolver. Instead, it delegates to the Go binary when necessary.
- Module Downloading: When a required module is missing locally, Hugo executes
go getviaexec.Commandto fetch it, then re-collects the tree to update the graph. - Workspace Support: If
Config.Workspaceis enabled, Hugo leverages Go's workspace mechanism to resolve multiple modules within a single repository, facilitating monorepo development. - Binary Detection: The client tracks
goBinaryStatusto differentiate between missing Go installations and configuration errors.
Practical Commands and Code Examples
To inspect your project's dependency graph, including replacement directives:
hugo mod graph
Programmatically, you can initialize a client and vendor modules:
c := modules.NewClient(modules.ClientConfig{
WorkingDir: projectDir,
Fs: afero.NewOsFs(),
Logger: loggers.NewDefault(),
ModuleConfig: modules.DefaultModuleConfig,
})
if err := c.Vendor(); err != nil {
log.Fatalf("vendor failed: %s", err)
}
To walk the collected modules and inspect their properties:
mc, coll := c.Collect()
if coll.err != nil {
panic(coll.err)
}
for _, m := range mc.AllModules {
fmt.Printf("%s@%s (path=%s, vendor=%t)\n",
m.Path(), m.Version(), m.Dir(), m.Vendor())
}
Summary
- Hugo Modules are standard Go modules augmented with Hugo-specific metadata like mounts and replacements.
- The resolution flow in
modules/collect.gorecursively parsesgo.modfiles and applies site-specific replacements frommodules/config.go. - First-module-wins: Duplicate mounts are filtered to ensure deterministic override behavior.
- Vendoring stores dependencies in
_vendorwith a manifest file, whilehugo mod tidyprunes unused dependencies. - The system delegates to the Go binary for downloading but manages the graph enrichment internally.
Frequently Asked Questions
How does Hugo resolve conflicting versions of the same module?
Hugo relies on Go's minimal version selection (MVS) algorithm to resolve versions, but applies its own replacement and mount logic afterward. When multiple modules require the same dependency, Go selects the highest required version, and Hugo then deduplicates mounts so the first module in the dependency list controls shared directories.
What is the difference between vendor and _vendor in Hugo?
Standard Go projects use a vendor directory, but Hugo uses _vendor (as defined in modules/client.go) to store vendored module files. This distinction prevents conflicts with Go's native vendoring while allowing Hugo to maintain its own manifest format in modules.txt and handle asset-specific requirements.
Can Hugo manage dependencies without Go installed?
No. While Hugo caches module information, the Client in modules/client.go checks for the Go binary (goBinaryStatus) and executes go get commands when modules are missing. Without Go installed, Hugo cannot download new dependencies or resolve the full graph, though it can operate with previously vendored modules.
How do replacement directives work in Hugo Modules?
Replacements are specified in your Hugo configuration file (not just go.mod) under the modules section and are parsed by modules/config.go. When the collector builds the dependency tree, it substitutes the replacement path for the original module path, allowing you to develop themes locally or fork modules without modifying import paths.
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 →