Skip to content

Go Modules

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 mod commands require the go binary and a file system — they cannot run in the in-browser runner. Follow the steps below in your terminal.

Terminal window
mkdir myapp && cd myapp
go mod init github.com/yourname/myapp

This creates a go.mod file — the module’s manifest:

module github.com/yourname/myapp
go 1.27.0

The 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:

Terminal window
go get [email protected] # rewrites the go line to 1.27.x

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.27 is the language and library version the module is written against — the compiler enforces it, and since Go 1.27 go test’s stdversion check refuses calls to APIs newer than this line.
  • toolchain go1.27.1 (optional) pins the exact toolchain; an older go binary downloads and runs it automatically.
  • ignore (Go 1.25) tells the go command to skip a directory tree entirely — useful for large node_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.

Terminal window
# Add the latest version of a module
go get github.com/go-chi/chi/v5
# Add a specific version
go get github.com/go-chi/chi/[email protected]
# Upgrade a dependency to its latest patch/minor release
go get -u github.com/go-chi/chi/v5

After go get, your source code can import the package:

import "github.com/go-chi/chi/v5"

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:

Terminal window
go mod tidy

It 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.

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.

go.mod
require github.com/google/uuid v1.6.0
$ go mod graph
example.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.

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:

Terminal window
go mod why github.com/google/uuid
github.com/google/uuid
example.com/modw
github.com/google/uuid

This 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 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-bar
replace github.com/foo/bar => github.com/yourfork/bar v1.2.3-patched

replace 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:

Terminal window
go mod edit -replace github.com/foo/bar=../local-bar
go mod edit -exclude github.com/bad/[email protected]
go mod edit -retract v1.0.1
replace github.com/foo/bar => ../local-bar
exclude github.com/bad/pkg v1.0.0
retract v1.0.1

go 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:

Terminal window
go work init ./app ./lib
go 1.26.5
use (
./app
./lib
)
Terminal window
cd app && go run .
# hello from local lib

app/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.

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.

Terminal window
go install golang.org/x/tools/cmd/stringer@latest # installs a CLI binary — go.mod untouched
go get golang.org/x/tools/cmd/stringer # WRONG for a tool — adds a require line

Use 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.

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.

Terminal window
go get -tool golang.org/x/tools/cmd/stringer@latest

Verified 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:

Terminal window
go tool stringer -h

Every 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:

VariableDefaultPurpose
GOPROXYhttps://proxy.golang.org,directWhere modules are fetched from; direct falls back to the module’s VCS
GOSUMDBsum.golang.orgThe 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
Terminal window
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 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=0

Verified 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.

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.

Terminal window
go env GOMODCACHE
go clean -modcache # deletes the entire cache — next build re-downloads everything it needs

Treat 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.

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.

Terminal window
# v1 — no suffix needed
go get github.com/foo/[email protected]
# import "github.com/foo/bar"
# v2+ — /v2 suffix is part of the import path
go get github.com/foo/bar/[email protected]
# 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.

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"]
Minimal version selection picks the highest version anything in the graph actually asks for
What you getBenefitCost
go.mod (explicit versioning)Reproducible builds, dependencies stated explicitlyNeeds updating as dependencies change — run go mod tidy often
Minimal version selectionDeterministic, reproducible resolution — no surprise upgradesYou must explicitly go get -u to move forward; it never happens implicitly
go work for local multi-module devNo replace lines to remember to remove before publishingAnother file to keep out of version control and out of sync with teammates
The tool directiveTool versions tracked in go.mod, no tools.go boilerplateRequires Go 1.24+ tooling awareness from contributors
go.sum (hash verification)Guards against supply-chain attacks, verifies integrityMust commit go.sum too — easy to forget
go mod vendorEnables air-gapped builds, no network dependencyThe vendor/ directory is large and must be synced on every dependency update
  • go get works like npm install — go get updates go.mod and downloads source; it does not install a separate binary, unlike npm. Use go install pkg@version for that.
  • You can ignore go.sum — go.sum must be committed alongside go.mod to verify dependency integrity — without it the build may not be reproducible.
  • The replace directive is never allowed in production — replace in the main module’s go.mod is fine (e.g. a fork you maintain) but it has no effect on anyone who imports your module — a dependent’s go.mod never sees your replace.
  • GOPATH/pkg/mod is permanent storage — go clean -modcache clears 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.go file with blank imports is still the way to pin tool versions — since Go 1.24, go get -tool and the tool directive in go.mod do this natively, with no build-tag trick needed.

💡 Real-world example

Kubernetes — its go.mod lists several hundred direct dependencies, and vendor/ is committed so the build is reproducible in every environment.

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 get inside application code’s setup instructions, rather than go install pkg@version or the tool directive? That silently adds it as a real dependency of your module.
  • Does a replace directive in a library’s go.mod point 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 — replace in a published library is a red flag, not a private convenience.
  • Is GOFLAGS=-insecure or GONOSUMCHECK/GOSUMDB=off set globally in a script or CI config “to make the build work”, rather than scoping the exception to the specific private module path with GOPRIVATE?
  • Does generated setup documentation still describe a tools.go file with //go:build tools blank imports, when the project’s go.mod already declares go 1.24 or later and could use the tool directive instead?
  • Is go.work committed 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

Terminal window
# setup.sh — generated project bootstrap script
go get golang.org/x/tools/cmd/stringer@latest
go get github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
Show 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.

Which command initialises a new Go module in the current directory?
Which version of github.com/foo/bar does minimal version selection put in the build?
$ go mod graph
yourmodule github.com/foo/[email protected]
yourmodule github.com/baz/[email protected]
github.com/baz/[email protected] github.com/foo/[email protected]
A library you publish has this in its go.mod: What happens when someone else imports your library?
replace github.com/foo/bar => ../local-fork-of-bar
What does go mod tidy do?
A setup script uses this to install the stringer code-generation tool. What is wrong, and what is the Go 1.24+ fix?
go get golang.org/x/tools/cmd/stringer@latest
A module releases v2.0.0 with breaking changes. How must the import path change?