TechGuild
1Level 1, Curious Newcomer

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.

12 min readintermediateFacts checked August 2026
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 , 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.

GoTypeScriptJava
Manifestgo.modpackage.jsonpom.xml
A declared version meansat least this exact versiona range the resolver may satisfy with anything newerthis coordinate, with ranges available and rare
Conflict rulethe highest version required anywhere in the graphper range, and the same package may appear twicethe declaration nearest the root wins
Repeatabilityrecomputed identically from the requirementspackage-lock.json records the resolutionno , and a range or a snapshot ends it

Go has no 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 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 , 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.