Go Modules and go.mod
A Go module is a collection of related Go packages that are versioned and released together as a single unit, and it is the mechanism modern Go uses to manage dependencies. Every module is described by a file named go.mod, which records the module’s own import path, the minimum Go version it targets, and the exact versions of every external package it needs. Modules replaced the old GOPATH workflow, where all your code had to live inside one global workspace directory; with modules, a project can live anywhere on disk and still produce reproducible, verifiable builds. Understanding go.mod and its companion file go.sum is essential, because almost every real Go codebase you touch will use them.
Overview: How Go Modules Work
A module is simply a tree of Go source files with a go.mod file at its root. The go.mod file declares the module’s module path — a string like example.com/hello that acts as the import prefix for every package inside the module, and often doubles as the location (a version-control URL) where the module’s source can be fetched. When you write import "example.com/hello/util" inside a module whose go.mod declares module example.com/hello, the Go tool knows that package lives in the local util subdirectory, not out on the network.
The go directive in go.mod records the minimum Go language version the module requires. This is not just documentation: the compiler uses it to decide which language features and standard-library behaviors are allowed, so bumping it should reflect an actual dependency on newer behavior, not be done casually.
External dependencies are listed under require directives, each pairing a module path with a specific version (Go uses semantic versioning, e.g. v1.6.0). When you run go build, go test, or go run, the Go tool walks the import graph, and for every imported package it does not find locally or in the standard library, it looks up which module supplies it. Since Go 1.16, the tool no longer silently rewrites go.mod during an ordinary build the way older versions did — if a required module is missing, the build fails with an explicit error telling you to run go get.
When multiple dependencies require different versions of the same module, Go resolves the conflict with an algorithm called Minimum Version Selection (MVS): it picks the lowest version that still satisfies every requirement in the whole build, not the newest available one. This is the opposite of how many other package managers behave, and it is deliberate — it keeps builds reproducible, because adding a new, unrelated dependency elsewhere in the tree can never silently upgrade a package you already depend on.
Every module version that gets used is also recorded in go.sum, a file full of cryptographic hashes (one for the module’s source tree, one for its go.mod file). Every time a module is downloaded — whether from the module cache on disk or from a proxy like proxy.golang.org — its hash is checked against go.sum. If they don’t match, the build refuses to continue. This is Go’s supply-chain integrity check: it means nobody can quietly swap out the code behind a version tag you already trust.
Downloaded modules are stored in a local, read-only module cache (under $GOPATH/pkg/mod by default) shared across every project on your machine, so the same dependency version is only ever downloaded and verified once. Modules also support semantic import versioning: once a module reaches a breaking change at major version 2 or above, its module path must carry a version suffix, such as github.com/foo/bar/v2. This lets v1 and v2 of the same library be imported side by side in the same program, because to Go they are simply different import paths.
Syntax
A minimal go.mod looks like this:
module example.com/hello
go 1.21
The general shape of a go.mod file uses a small set of directives:
| Directive | Purpose |
|---|---|
module |
Declares the module’s own import path. Always the first line. |
go |
Minimum Go language version this module requires. |
require |
A dependency module path and the minimum version needed. Can appear as single lines or grouped in a require ( ... ) block. |
exclude |
Forbids the build from selecting a specific version of a dependency (rare; used to blacklist a known-broken release). |
replace |
Substitutes one module path/version for another — commonly a local filesystem path, used during development or to pin a fork. |
retract |
Used by a module’s own author to mark one of their own previously published versions as broken, so others avoid selecting it. |
Examples
Example 1: Creating and building your first module
Every module starts with go mod init <module-path>, which writes a starter go.mod. Given this main.go:
package main
import "fmt"
func main() {
fmt.Println("Hello, Go modules!")
}
Output:
Hello, Go modules!
you would set it up and build it like this:
$ go mod init example.com/hello
go: creating new go.mod: module example.com/hello
$ go build
$ ./hello
Hello, Go modules!
go mod init writes the module and go lines shown earlier. Because this program only imports the standard library package fmt, no require entries are needed — the standard library ships with the Go toolchain itself and is never listed in go.mod.
Example 2: A program that uses only the standard library
package main
import (
"fmt"
"strings"
)
func main() {
sentence := "Go modules manage your project's dependencies"
words := strings.Fields(sentence)
fmt.Println("Word count:", len(words))
for i, w := range words {
fmt.Printf("%d: %s\n", i+1, w)
}
}
Output:
Word count: 6
1: Go
2: modules
3: manage
4: your
5: project's
6: dependencies
strings.Fields splits the sentence on whitespace, producing six words, which the loop then numbers and prints. Even though this file lives inside a module, resolving fmt and strings never touches the network or the module cache — only third-party import paths trigger module resolution.
Example 3: Adding a real dependency
Once you import a third-party package, running go get (or go mod tidy) fetches it and records it. After adding two example dependencies, go.mod would look like this:
module example.com/greeter
go 1.21
require (
github.com/google/uuid v1.6.0
github.com/fatih/color v1.17.0
)
and go.sum gains matching hash entries (shown here abbreviated for illustration; the go tool generates the real ones automatically — you should never type them by hand):
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl+F8AaQ2h9J0vGkKS4A0=
From now on, anyone who clones this repository and runs go build gets exactly these dependency versions, verified byte-for-byte against go.sum, with no extra setup steps.
How It Works Step by Step
When you run go build inside a module, roughly this sequence happens:
- The tool walks upward from the current directory until it finds a
go.mod, which fixes the module root. - It reads the
modulepath and thegodirective, which sets the language version semantics for compilation. - It scans your source files and builds the graph of imported packages.
- For each import that isn’t part of the current module or the standard library, it looks up the required module and version from
go.mod(and transitively from thego.modfiles of your dependencies), applying Minimum Version Selection to pick one version per module across the whole graph. - Any module version not already in the local module cache is downloaded (through
GOPROXY,proxy.golang.orgby default) and its checksum is verified againstgo.sumbefore it’s trusted. - Packages are compiled bottom-up, dependencies first, with build artifacts cached so unchanged packages aren’t recompiled on the next build; the final step links everything into the output binary.
Common Mistakes
Mistake 1: Module path doesn’t match your import statements
If go.mod declares one module path but your imports assume a different one, the build fails because nothing in the module graph can supply that package:
// go.mod
module hello
go 1.21
// main.go
package main
import (
"fmt"
"example.com/hello/util" // ERROR: no required module provides package example.com/hello/util
)
func main() {
fmt.Println(util.Greet())
}
The module is declared as hello, but the import assumes it’s example.com/hello — those are different module paths as far as the tool is concerned. The fix is to make the declared module path match what your imports (and your intended public repository location) actually use:
// go.mod
module example.com/hello
go 1.21
// main.go
package main
import (
"fmt"
"example.com/hello/util" // OK: matches the module path declared in go.mod
)
func main() {
fmt.Println(util.Greet())
}
Mistake 2: Importing a package without updating go.mod
Adding an import to your source code doesn’t automatically add it to go.mod anymore — forgetting this step produces a build error instead of a silent fix:
$ go build
./main.go:8:2: no required module provides package github.com/google/uuid; to add it:
go get github.com/google/uuid
The fix, exactly as the error suggests, is to fetch and record the dependency explicitly, then tidy up:
$ go get github.com/google/uuid
$ go mod tidy
$ go build
$ ./greeter
go get adds the module to require and updates go.sum; go mod tidy additionally removes any require entries that are no longer imported by anything, keeping go.mod accurate.
Best Practices
- Always commit both
go.modandgo.sumto version control — together they make builds reproducible and tamper-evident. - Run
go mod tidybefore committing sogo.modexactly reflects what your code actually imports, no more and no less. - Change dependency versions with
go get module@version(or@latest,@noneto remove) rather than hand-editing version strings ingo.mod. - Keep the
godirective at the lowest version your code genuinely needs; don’t bump it speculatively. - Use
replacedirectives only for local development or private forks, and remove them before publishing a module that others will import. - For working across several local modules at once, prefer a
go.workworkspace file over scatteringreplacedirectives through each module’sgo.mod. - If you publish a module and make a breaking change, bump the major version and add the matching
/vNsuffix to the module path, per semantic import versioning. - Periodically run
go list -m -u allto see available updates andgo mod verifyto confirm your local module cache hasn’t been altered.
A go.work file for a multi-module workspace looks like this:
go 1.21
use (
./api
./worker
./shared
)
Practice Exercises
- Run
go mod init example.com/greetings, write a function that returns a greeting string, call it frommain, and build the resulting binary. - Deliberately import a package that isn’t in your
go.mod, observe the exact build error, then resolve it withgo getfollowed bygo mod tidy. - Create two small local modules in sibling directories and connect them with a
go.workfile instead of areplacedirective; confirmgo buildsucceeds across both without either module’sgo.modreferencing the other’s filesystem path.
Summary
- A Go module is a versioned tree of packages rooted at a
go.modfile, which declares the module path, minimum Go version, and required dependency versions. - Modules freed Go projects from the old
GOPATHrequirement — a project can live anywhere on disk. - Since Go 1.16, builds no longer auto-edit
go.mod; missing dependencies must be added explicitly withgo getorgo mod tidy. - Dependency versions are resolved with Minimum Version Selection, always picking the lowest version that satisfies every requirement, for reproducibility.
go.sumstores cryptographic hashes for every dependency version used, verified on every build to guard against tampering.- Modules with a major version of 2 or higher must carry a
/vNsuffix in their module path (semantic import versioning). - Use
replacefor local overrides and forks, andgo.workfor multi-module local development, rather than hand-wiring paths.
