Go Modules
What is a Go module?
Section titled “What is a Go module?”A module is a collection of Go packages that are versioned together and distributed as a unit. Every modern Go project starts with a module. The module system replaced the old GOPATH workflow in Go 1.11 and became the default in Go 1.16.
Playground note:
go modcommands require thegobinary and a file system — they cannot run in the in-browser runner. Follow the steps below in your terminal.
Initialising a module
Section titled “Initialising a module”mkdir myapp && cd myappgo mod init github.com/yourname/myappThis creates a go.mod file — the module’s manifest:
module github.com/yourname/myapp
go 1.27.0The module path (github.com/yourname/myapp) is both the import path prefix for your own packages and the identifier other modules use when they depend on yours. It does not have to be a real URL during development, but it must be unique if you publish to the Go module proxy.
Note the version: go mod init records the exact version of the toolchain that ran it — not a minor release below it. Confirmed locally: running it under Go 1.26.5 writes go 1.26.5; on Go 1.27.0 it writes go 1.27.0. To pin a specific minimum floor yourself, use:
go.mod and go.sum
Section titled “go.mod and go.sum”After adding a dependency you will find two files in the root of your module:
go.mod — declares the module path, the minimum Go version, and all direct and indirect dependencies:
module github.com/yourname/myapp
go 1.27
toolchain go1.27.1
ignore ./testdata/fixtures
require ( github.com/go-chi/chi/v5 v5.2.1)
require ( golang.org/x/text v0.28.0 // indirect)Three lines deserve a closer look:
go 1.27is the language and library version the module is written against — the compiler enforces it, and since Go 1.27go test’sstdversioncheck refuses calls to APIs newer than this line.toolchain go1.27.1(optional) pins the exact toolchain; an oldergobinary downloads and runs it automatically.ignore(Go 1.25) tells thegocommand to skip a directory tree entirely — useful for largenode_modules-style folders or fixtures that are not Go code.
The two require blocks are not a style choice: for go 1.27+ modules go mod tidy enforces this layout — direct dependencies in the first block, // indirect ones in the second — and merges any duplicate blocks it finds.
go.sum — a cryptographic checksum ledger. Every dependency’s zip archive and go.mod are hashed here. The Go toolchain verifies these hashes on every build to guarantee reproducibility and protect against supply-chain attacks. Commit both files to version control.
Adding a dependency
Section titled “Adding a dependency”# Add the latest version of a modulego get github.com/go-chi/chi/v5
# Add a specific version
# Upgrade a dependency to its latest patch/minor releasego get -u github.com/go-chi/chi/v5After go get, your source code can import the package:
import "github.com/go-chi/chi/v5"go mod tidy
Section titled “go mod tidy”Over time your code and your dependencies can drift apart — you might delete an import or add one without running go get. go mod tidy fixes both:
go mod tidyIt adds any missing require lines, removes require lines for packages no longer imported, updates go.sum to match, and (for go 1.27+ modules) normalises go.mod into the direct/indirect two-block layout. Run it before every commit.
Minimal version selection
Section titled “Minimal version selection”Go resolves which version of each dependency to actually use with an algorithm called minimal version selection (MVS): for every module in the build, Go takes the highest version requested by anything in the dependency graph, never the latest version that exists. If your module requires github.com/foo/bar v1.2.0 and a dependency of yours requires github.com/foo/bar v1.4.0, the build uses v1.4.0 — the maximum of what is actually asked for — not v1.9.0 just because it was released more recently.
require github.com/google/uuid v1.6.0$ go mod graphexample.com/modw github.com/google/[email protected]go mod graph prints every requirement edge in the build: <module> <requires> <at-version>. This is deterministic and reproducible on every machine given the same go.mod/go.sum — the opposite of a resolver that can pick a different “latest” on a different day.
go mod why
Section titled “go mod why”When you cannot tell why a dependency is in your module at all, go mod why prints the shortest import path that pulls it in. Verified locally:
go mod why github.com/google/uuidexample.com/modwgithub.com/google/uuidThis reads as a chain: your module imports github.com/google/uuid directly. A real project’s chain is usually longer — your-module → some-framework → some-utility-lib — and go mod why is the fastest way to find which direct dependency is dragging in something unexpected, rather than reading go.sum and guessing.
replace, exclude, retract, and go work
Section titled “replace, exclude, retract, and go work”replace substitutes a different source for a module path — a local directory during development, or a fork’s fixed version:
replace github.com/foo/bar => ../local-barreplace github.com/foo/bar => github.com/yourfork/bar v1.2.3-patchedreplace in your main module’s go.mod is completely normal — but it only affects builds of your own module. Anyone who imports your module never sees your replace line; their build resolves github.com/foo/bar normally. This is the single most common misunderstanding about the directive.
exclude removes a specific version from consideration entirely, forcing MVS to pick a different one — used for a version known to be broken or yanked. retract is the inverse, published from inside the affected module’s own go.mod: it tells everyone else’s tooling “don’t select this version of me”, surfaced as a warning by go list -m -u and go get. Verified against go help mod edit, go mod edit can write all three directly:
go mod edit -replace github.com/foo/bar=../local-bargo mod edit -retract v1.0.1replace github.com/foo/bar => ../local-barexclude github.com/bad/pkg v1.0.0retract v1.0.1go work solves the same local-development problem as replace without editing go.mod at all — useful for multiple modules you are developing together. Verified locally with two real modules, app (which requires example.com/lib v0.0.0, a version that does not exist) and a sibling lib directory:
go work init ./app ./libgo 1.26.5
use ( ./app ./lib)cd app && go run .# hello from local libapp/go.mod was never touched — no replace line was added — and the build still resolved example.com/lib to the local directory, because go.work overrides module resolution for every module listed in its use block. Delete or ignore go.work (it is meant to stay out of version control) and the build reverts to what go.mod alone says.
go install pkg@version vs go get
Section titled “go install pkg@version vs go get”Both fetch code, but they answer different questions. go get manages your module’s dependency graph — it edits go.mod/go.sum and is for packages your code imports. go install pkg@version builds and installs a standalone binary into $GOBIN (default $HOME/go/bin) and, per go help install, does so “ignoring the go.mod file in the current directory or any parent directory” — it never touches your project’s go.mod at all.
go install golang.org/x/tools/cmd/stringer@latest # installs a CLI binary — go.mod untouchedgo get golang.org/x/tools/cmd/stringer # WRONG for a tool — adds a require lineUse go install for developer tools (linters, code generators, stringer, mockgen) that you run from the command line. Reach for go get only for packages your source code actually imports.
The tool directive (Go 1.24)
Section titled “The tool directive (Go 1.24)”Before Go 1.24, projects pinned a tool’s version by creating a tools.go file with build-tag-guarded blank imports (//go:build tools followed by import _ "golang.org/x/tools/cmd/stringer") purely so go mod tidy would track its version — a well-known but awkward workaround. The tool directive replaces it: go get -tool adds a real, version-pinned tool entry to go.mod itself.
go get -tool golang.org/x/tools/cmd/stringer@latestVerified locally — the resulting go.mod:
module example.com/tooldir
go 1.26.0
tool golang.org/x/tools/cmd/stringer
require ( golang.org/x/mod v0.41.0 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/tools v0.50.0 // indirect)Run it straight from the module, no $GOBIN lookup needed:
go tool stringer -hEvery teammate who runs go mod tidy/go build gets the exact same tool version — the whole point tools.go was working around, without the blank-import boilerplate.
The proxy and privacy environment variables
Section titled “The proxy and privacy environment variables”By default go get/go mod download fetch from the public Go module proxy and verify against the public checksum database:
| Variable | Default | Purpose |
|---|---|---|
GOPROXY | https://proxy.golang.org,direct | Where modules are fetched from; direct falls back to the module’s VCS |
GOSUMDB | sum.golang.org | The checksum database used to verify downloads against tampering |
GOPRIVATE | (empty) | Glob patterns of module paths to fetch directly and skip both the proxy and the sumdb — for internal/private repositories |
GONOSUMCHECK / GOFLAGS=-insecure | — | Legacy/edge-case escape hatches; GOPRIVATE (or the narrower GONOSUMDB/GONOSUMCHECK) is the supported way to opt a path out |
export GOPRIVATE=github.com/yourcompany/*Setting GOPRIVATE for your company’s org is the standard fix for “go get hangs or fails on our internal repo” — without it, the public proxy and sumdb are consulted for a module path they can never actually see.
The toolchain and godebug directives
Section titled “The toolchain and godebug directives”The toolchain directive (toolchain go1.27.1 in go.mod) pins the exact Go toolchain version. If the go binary that invoked the build is older than the pinned toolchain, it downloads and runs the pinned one automatically — controlled by GOTOOLCHAIN (default auto; set to local to disable automatic downloads and always use the installed go binary).
The godebug directive sets a GODEBUG setting at the module level instead of per-process — it ships with the module so every build gets the same runtime behavior for a setting that changed defaults across Go versions:
godebug http2client=0Verified via go help mod edit: go mod edit -godebug=key=value writes this line directly. This is how the toolchain lets a module opt back into an old default (or opt into a new one early) without every developer setting an environment variable by hand.
The module cache
Section titled “The module cache”Every downloaded module version is extracted once into a shared, read-only cache at GOMODCACHE (default $GOPATH/pkg/mod) and reused across every module on the machine that needs that exact version — verified locally, this directory holds 1.8 GB after routine work on this course.
go env GOMODCACHEgo clean -modcache # deletes the entire cache — next build re-downloads everything it needsTreat it purely as a cache: safe to delete when disk space is tight, and Go re-populates it from GOPROXY (or direct) the next time something needs a module version that is missing.
Semantic import versioning
Section titled “Semantic import versioning”Go enforces a rule called semantic import versioning: if a module releases a v2 or higher, the import path must include the major version suffix.
# v1 — no suffix needed# import "github.com/foo/bar"
# v2+ — /v2 suffix is part of the import path# import "github.com/foo/bar/v2"This means two major versions of the same module can coexist in the same binary without conflict. The import path change is intentional: a v2 API is allowed to be incompatible with v1.
How a version is selected
Section titled “How a version is selected”flowchart TD
A["your go.mod requires foo v1.2.0"] --> D["minimal version selection"]
B["dependency X requires foo v1.4.0"] --> D
C["dependency Y requires foo v1.1.0"] --> D
D --> E["build uses foo v1.4.0 — the maximum requested, never a newer 'latest'"]
E --> F{"exclude foo v1.4.0?"}
F -->|yes| G["MVS picks the next-highest requested version instead"]
F -->|no| H["go.sum records and verifies v1.4.0's checksum"] Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
go.mod (explicit versioning) | Reproducible builds, dependencies stated explicitly | Needs updating as dependencies change — run go mod tidy often |
| Minimal version selection | Deterministic, reproducible resolution — no surprise upgrades | You must explicitly go get -u to move forward; it never happens implicitly |
go work for local multi-module dev | No replace lines to remember to remove before publishing | Another file to keep out of version control and out of sync with teammates |
The tool directive | Tool versions tracked in go.mod, no tools.go boilerplate | Requires Go 1.24+ tooling awareness from contributors |
go.sum (hash verification) | Guards against supply-chain attacks, verifies integrity | Must commit go.sum too — easy to forget |
go mod vendor | Enables air-gapped builds, no network dependency | The vendor/ directory is large and must be synced on every dependency update |
Common Misconceptions
Section titled “Common Misconceptions”go getworks likenpm install—go getupdatesgo.modand downloads source; it does not install a separate binary, unlike npm. Usego install pkg@versionfor that.- You can ignore
go.sum—go.summust be committed alongsidego.modto verify dependency integrity — without it the build may not be reproducible. - The
replacedirective is never allowed in production —replacein the main module’sgo.modis fine (e.g. a fork you maintain) but it has no effect on anyone who imports your module — a dependent’sgo.modnever sees yourreplace. GOPATH/pkg/modis permanent storage —go clean -modcacheclears it — treat it as a cache, not permanent storage.- Go picks the “latest” version of every dependency automatically — minimal version selection picks the minimum version that satisfies every requirement in the graph, not the newest one available; upgrades are explicit.
- A
tools.gofile with blank imports is still the way to pin tool versions — since Go 1.24,go get -tooland thetooldirective ingo.moddo this natively, with no build-tag trick needed.
💡 Real-world example
Kubernetes — its
go.modlists several hundred direct dependencies, andvendor/is committed so the build is reproducible in every environment.
Reading AI-generated code
Section titled “Reading AI-generated code”Module and dependency mistakes rarely show up as a compile error — they show up as “works on my machine” or a security surprise. Check for these:
- Is a CLI developer tool (a linter, a code generator) installed with
go getinside application code’s setup instructions, rather thango install pkg@versionor thetooldirective? That silently adds it as a real dependency of your module. - Does a
replacedirective in a library’sgo.modpoint at a local path (../something) or an unpublished fork? That breaks the build for every consumer the moment they try to build outside the exact checkout the author had —replacein a published library is a red flag, not a private convenience. - Is
GOFLAGS=-insecureorGONOSUMCHECK/GOSUMDB=offset globally in a script or CI config “to make the build work”, rather than scoping the exception to the specific private module path withGOPRIVATE? - Does generated setup documentation still describe a
tools.gofile with//go:build toolsblank imports, when the project’sgo.modalready declaresgo 1.24or later and could use thetooldirective instead? - Is
go.workcommitted to version control in a library repository? It is meant for local, personal multi-module development — committing it can silently redirect teammates’ builds to local paths that do not exist on their machines.
🐛 Spot the bug
# setup.sh — generated project bootstrap scriptgo get golang.org/x/tools/cmd/stringer@latestgo get github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latestShow the answer
Both of these are developer tools, not packages the project’s own code imports — but go get adds each one as a real dependency in go.mod’s require block anyway, because that is what go get is for. Every teammate’s go mod tidy and every CI build now downloads and tracks stringer and golangci-lint as if the application depended on them at runtime. The fix is go install golang.org/x/tools/cmd/stringer@latest and go install .../golangci-lint@latest (which install binaries into $GOBIN without touching go.mod at all), or — if the team wants the version pinned and shared through git — go get -tool golang.org/x/tools/cmd/stringer@latest, which uses the tool directive built for exactly this case.