Modules, versions and the toolchain that downloads itself
`go.mod` is not `package.json` and not `pom.xml`. This lesson covers module initialisation, minimal version selection, the private module path that fails on day one, the toolchain directive that is a floor rather than a pin, and the `tool` directive that replaced the tools.go trick.
On this page
What you will be able to do
- Initialise a module and read every line of the resulting go.mod
- Explain minimal version selection and predict what go mod tidy will and will not upgrade
- Force an exact toolchain and explain why the toolchain directive does not do it
- Make a private module path resolve, and say what a vendor directory changes
- Track an executable dependency with the tool directive
There is no lockfile, and no line in go.mod is a version you are guaranteed to get. Every
requirement in the file is a minimum. The exact set of versions a build uses is computed from those
minimums each time, by a rule simple enough to run in your head, which is why Go ships no resolved
tree to check in. That single decision explains most of what surprises people arriving from npm or
Maven, starting with the fact that go mod tidy will not upgrade anything.
| Go | TypeScript | Java | |
|---|---|---|---|
| Manifest | go.mod | package.json | pom.xml |
| A declared version means | at least this exact version | a range the resolver may satisfy with anything newer | this coordinate, with ranges available and rare |
| Conflict rule | the highest version required anywhere in the graph | per range, and the same package may appear twice | the declaration nearest the root wins |
| Repeatability | recomputed identically from the requirements | package-lock.json records the resolution | no lockfile, and a range or a snapshot ends it |
Go has no lockfile because go.sum is not one. It holds hashes for the content and the go.mod of
every module in the graph, so it answers "are these the bytes everyone else got", not "which
versions did we settle on". Resolution is already deterministic, so there is nothing else to record.
Starting a module, and reading what it wrote
mkdir app && cd app
go mod init example.com/app
The argument is the module path, and it is the import prefix for every package beneath it. It need
not resolve to anything while the code is private, but the moment somebody else imports it the path
has to be fetchable, which is why real modules are named github.com/you/app.
That writes a module line and a go line. The go line is not metadata: it is the language
version, and it selects the semantics the compiler applies to every file in this module. Read the
value it actually wrote rather than assuming one: the version a fresh go mod init derives from your
toolchain has moved between releases, and the 1.26 release notes and the observed behaviour of a 1.26
toolchain do not agree about it.
That gate is also what decides the loop variable semantics a module gets, which functions, closures and iterators covers in full.
Two commands are easy to confuse. go get example.com/[email protected] edits go.mod and populates the
module cache; it builds no binary. go install golang.org/x/tools/cmd/stringer@latest builds one
command into GOBIN and ignores your module entirely; swap latest for a version and you get
exactly that build, which is what a CI script wants. Installing binaries stopped being go get's job
several releases ago, and outside a module it refuses outright with 'go get' is no longer supported outside a module.
Minimal version selection
- minimal version selection
The version Go selects for a dependency is the highest version that any module in the graph explicitly requires, and no higher. Adding or raising a requirement can move it; a newer release appearing on the proxy cannot.
Nothing in the graph carries a range, so there is no resolver making a judgement call and no
opportunity for two machines to disagree. The consequence people trip over is that go mod tidy is
not an update command. It adds requirements for packages you import, drops the ones you no longer
import, and fixes up go.sum. It will not raise a version, ever.
Two commands make the computed answer visible. go list -m all prints the selected build list, the
closest thing Go has to a lockfile and generated rather than stored. go mod why example.com/dep
prints the import chain keeping a module in the graph, which is how you learn that a dependency you
never chose arrived through one you did.
The private module path, which fails on your first day
A module path is fetched as a URL, and two public services stand in front of it: the proxy, since
GOPROXY defaults to https://proxy.golang.org,direct, and the checksum database at
sum.golang.org. Neither can see your employer's repository.
GOPRIVATE turns both off for the paths you name, as a comma-separated list of path.Match globs
over module path prefixes. It sets the default for GONOPROXY, which sends those paths straight to
the source, and for GONOSUMDB, which stops the checksum database being asked about them; set those
two by hand only when they differ, usually because a company proxy does serve private modules. The
insteadOf rule below is the other half, handing the fetch to SSH and the key you already have, and
go env -w writes the setting where it outlives the shell.
go env -w GOPRIVATE='github.com/acme/*'
git config --global url."[email protected]:acme/".insteadOf "https://github.com/acme/"
A vendor/ directory changes the rules again. go mod vendor copies in every package needed to
build and test the module, and from then on, for a go line of 1.14 or above, the go command acts as
if -mod=vendor were set, building from those files and consulting neither the module cache nor the
network. That is what an air-gapped CI wants, and it is why a go.mod edit you forget to re-vendor
stops the build complaining about inconsistent vendoring rather than downloading anything.
The toolchain line is a floor
GOTOOLCHAIN defaults to auto, which means the go command downloads and runs a toolchain other
than the one you installed whenever go.mod asks for a newer one. That is the toolchain downloading
itself, and it is why a colleague on an older Go can still build your module.
The trap is the other direction. Put go 1.24.0 and toolchain go1.25.12 in go.mod, build it on
a machine with Go 1.26.5 installed, and a program printing runtime.Version() prints go1.26.5.
The line raised the floor and nothing else. Much published writing calls it a pin; it is not, and a
build that must use one specific compiler will not get it from go.mod.
To force an exact toolchain, set the environment variable: GOTOOLCHAIN=go1.25.12 go run . runs
that compiler and fetches it if it is missing. To forbid fetching entirely, GOTOOLCHAIN=local.
Two modules at once, without a replace directive
You are changing a library and the command that uses it in the same afternoon. The old answer was a
replace directive in go.mod, which works and which somebody eventually commits by mistake.
go work init ./lib ./cli
That writes go.work with a use block naming both directories, and builds anywhere in the tree
then resolve example.com/lib to your working copy, with no tag and no publish step in between. It
describes one person's checkout rather than the project, so it belongs in .gitignore. One surprise
is worth carrying: a go.work governs every module beneath it whether you meant it to or not, so a
third module added in ./other and left out of use fails to build with directory prefix . does not contain modules listed in go.work or their selected dependencies. The message names no file.
go env GOWORK tells you which workspace has you, go work use ./other ends it, and GOWORK=off
in front of a command confirms the module still builds on its own.
Tracking a tool as a dependency
Code generators and linters have versions too, and a team each running a different one argues about
output rather than code. The old fix was a tools.go file with a never-satisfied build constraint,
importing command packages so that go mod tidy would keep them. Go 1.24 replaced it with a real
directive.
go get -tool golang.org/x/tools/cmd/stringer@latest
That adds tool golang.org/x/tools/cmd/stringer to go.mod, and go tool stringer builds and runs
it at the version your module requires. go tool with no arguments lists the toolchain's own
commands first and your module's tools after them. The cost is honest: the tool's dependencies join
your module graph as indirect requirements, so a fat linter widens go.mod for everyone.
Pinning a linter this way, and the checks worth running, belong to formatting, vet and linters. Build flags and what they do to the binary belong to building, embedding and logging. Next, reading Go and laying out a project takes on the directory tree that these files sit at the top of.
Check your understanding
Sign in to take this check
4 questions on this lesson, one at a time, with the reasoning for every option as soon as you answer. Each answer is marked on the server and stored against your account.
An account is free. There is no paid plan, no tier and nothing to buy.