Go modules and dependencies: go.mod, go.sum and minimal version selection
Interview questions on modules climb a ladder: what a module is — which version go picks when two libraries require different ones — what go get and go mod tidy do — why go.sum — why the /v2 import — how to swap a dependency for a local copy — what the go line in go.mod means. A run answers each step: a asked for c v1.1.0, b for v1.3.0, the proxy held v1.9.0, and v1.3.0 was built — for a too; go refused to build an archive of the same version replaced in the proxy, printing SECURITY ERROR; and a replace in a dependency's go.mod did not work at all — the folder it pointed to did not exist, and that was not an error.
Full technical treatment
TL;DR
A module is a set of packages released under one version, and the go.mod
at its root is a list of requirements: the path of each dependency and the
lowest version the module agrees to. The version that ends up in the build is
not named by go.mod — the go command computes it over the whole requirement
graph. There is no separate lock file, and none is needed: the same graph with
the same settings (replace, go.work, -mod flags) gives the same list of
selected versions. That is a promise about versions, not about an identical
binary: the binary also depends on the toolchain, build flags and platform.
Out of several requirements the highest one is selected — and not a version
more. a requires c v1.1.0, b requires c v1.3.0, and the proxy also
holds v1.9.0. go list -m all shows example.com/c v1.3.0, and a, which
asked for v1.1.0, also gets v1.3.0: a build has one copy per module. A new
version in the proxy does not change the build — only a new requirement does,
and go get and go mod tidy write it.
Beyond that comes what separates real understanding from having read about
it. The lines of go.sum come in two kinds — the hash of the module's files
and the hash of its go.mod; how many there are depends on the graph, and the
count follows no rule. go refuses to build an archive of the same version
replaced in the proxy, with SECURITY ERROR. Downgrading c to v1.1.0 also downgrades
b, which needs v1.3.0, and when there is no suitable version of b it
throws b out: removed example.com/b. v2 is a different import path, and c and c/v2
legitimately live in one build. replace works only in the main modules — in
the main module, and in a go.work workspace in each of its modules and in
go.work itself; in an ordinary dependency's go.mod it silently does nothing.
It replaces the source — and with it the requirements from the replacement's
go.mod. And the line go 1.21 does not
let for i := range 3 compile — it sets the language version — while
go 1.25.0 with GOTOOLCHAIN=local does not let go1.24.7 build the module at
all.
- what a package and an import path are — that is the lesson on packages;
- how to run
go buildandgo run; - that a version like
v1.3.0is made of the major, minor and patch parts of semantic versioning: minor and patch promise compatibility, major does not (in detail — Mechanism 4).
- minimal version selection,
go get,go mod tidyand// indirect; - what lies in
go.sumand whatgodoes when a hash does not match; the/v2import; replace, thegoandtoolchainlines,GOTOOLCHAIN.
What is really being asked
The ladder is almost always this one:
- "What is a module, and how does it differ from a package?" — the warm-up.
- "Two libraries require different versions of one dependency — which one will go take?" — the main question of the topic, and "the latest" is the wrong answer to it.
- "What does
go getdo, what doesgo mod tidydo, and what does// indirectmean?" — this checks whether the candidate understands thatgo.modis written by commands, not only by hand. - "Why
go.sum? Should it be committed?" — about integrity checking. - "Why does the second major version have
/v2in its path?" — about semantic import versioning. - "How do you swap a dependency for a local copy?" —
replaceand its limits. - "What does the line
go 1.22ingo.modmean?" — the language version and the toolchain.
The ladder has a single spine: go.mod stores requirements of the form "no
lower than such a version", and the build's version is chosen by the graph — by
the requirements, not by what lies in the proxy. Almost everything asked next
follows from that sentence.
What this was checked on: the runs are go1.24.7 and go1.25.1, linux/amd64; the
quotes from go help, the specification and the source were verified on
14.10.2026. By that date Go 1.26 and 1.27 were out; the lesson was not run on
them. The texts of the go command's messages in the run blocks are the output
of these two versions, not a contract: in another version the wording may
differ.
Base: a module is a tree of packages with go.mod at its root
A module is a collection of packages that are released, versioned, and
distributed together.
A package is a directory of .go files; a module is a tree of such directories
with go.mod at the root. The version belongs to the module as a whole, not to
a single package. The module path from the module line is the prefix of the
import paths of all its packages.
The shortest module is one file and one command. The full file main.go (it is
also printed in section 8 of the run); it can be put into an empty directory and
run with two commands — go mod init example.com/hello and go run .:
package main
import (
"fmt"
"runtime/debug"
)
func main() {
info, _ := debug.ReadBuildInfo()
fmt.Println("module:", info.Main.Path)
}Output of bench/gomod/internals.go, section 8; labels translated:
$ go mod init example.com/hello
go: creating new go.mod: module example.com/hello
go: to add module requirements and sums:
go mod tidy
$ go run .
module: example.com/hello
go.mod after go mod init:
module example.com/hello
go 1.24.7
go mod init wrote the module path and the go line — the version of the
installed Go (on go1.25.1 it is go 1.25.1). There are no requirements: the
module uses only the standard library. The program learned its module path from
the build information that go embeds into the binary.
Next come dependencies, and for them a module proxy: a server that, given a
module path and a version, serves that version's go.mod and an archive of its
sources; by default it is proxy.golang.org. The lesson's run builds a small
requirement graph — who requires which module at which version. It has no
network, and needs none: a module proxy is just files, and the documentation says so outright — even a
site serving from a fixed file system (including a file:/// URL) can be a
proxy. The script itself puts the modules example.com/a, b, c and a few
more into such a proxy, and then runs real go commands with their own module
cache. The main module app imports a and b, and both of them import c.
Here is its go.mod after go mod tidy:
Output of bench/gomod/internals.go, section 1; labels translated:
go.mod after go mod tidy:
module example.com/app
go 1.24
require (
example.com/a v1.0.0
example.com/b v1.0.0
)
require example.com/c v1.3.0 // indirect
Three kinds of lines. module is the module path. go is the language version
and the lowest Go version the module may be built with (that is in "Deeper").
require lists requirements: a module path and the lowest version. The
first block is what app imports itself; the second, marked // indirect, is
what its dependencies need; go mod tidy writes both blocks.
One table is worth memorizing right away:
| file | what it holds | who writes it |
|---|---|---|
go.mod | requirements "module, no lower than version"; go, toolchain, replace | you, go get, go mod tidy |
go.sum | h1: hashes of the module's files and of its go.mod for the build's modules (and the go.mod of some unselected versions if dependencies declare go below 1.17) | go get, go mod tidy |
| module cache | unpacked sources, go env GOMODCACHE | go, when downloading |
A Junior-level answer: a module is a set of packages released under one
version; the go.mod at its root sets the module path, the Go version and the
requirements on dependencies, and go.sum stores their checksums. The word
worth saying separately is "requirements": go.mod records not the installed
version but a lower bound.
Mechanism 1: minimal version selection — the highest of the required
The run's graph: a requires c v1.1.0, b requires c v1.3.0, and the proxy
has four versions of c. What go selects:
Output of bench/gomod/internals.go, section 1; labels translated:
1) MINIMAL VERSION SELECTION
────────────────────────────
graph: app → a v1.0.0 → c v1.1.0; app → b v1.0.0 → c v1.3.0
$ go mod tidy
(no output)
$ go list -m -versions example.com/c
example.com/c v1.1.0 v1.3.0 v1.5.0 v1.9.0
$ go list -m all
example.com/app
example.com/a v1.0.0
example.com/b v1.0.0
example.com/c v1.3.0
$ go run .
a.CVersion() = v1.3.0
b.CVersion() = v1.3.0
Three things in this output:
v1.3.0is selected — the highest of the two required;- not
v1.9.0, even though it is in the proxy: nobody requires it; - there is one copy of
cin the build, anda, which asked forv1.1.0, works withv1.3.0. Two versions of one module in one build do not happen (except for different major versions — that is Mechanism 4).
The algorithm is called exactly that — Minimal Version Selection, the package
cmd/go/internal/mvs. The comment on the function that builds the build list
says it traverses the graph and returns a list containing the highest version
for each visited module. "Highest" and "minimal" do not contradict each other
here: of the visited versions the highest is taken, and that is the
minimal version that satisfies all requirements. No requirement asks for
more than v1.3.0, so the build does not get more than v1.3.0.
go mod graph prints the graph itself:
Output of bench/gomod/internals.go, section 1; labels translated:
$ go mod graph
example.com/app example.com/a@v1.0.0
example.com/app example.com/b@v1.0.0
example.com/app example.com/c@v1.3.0
example.com/app go@1.24
example.com/a@v1.0.0 example.com/c@v1.1.0
example.com/a@v1.0.0 go@1.22
example.com/b@v1.0.0 example.com/c@v1.3.0
example.com/b@v1.0.0 go@1.22
example.com/c@v1.3.0 go@1.22
go@1.24 toolchain@go1.24
The edge a@v1.0.0 → c@v1.1.0 is in the graph, but c v1.1.0 does not go into
the build. The third line, app → c@v1.3.0, is that very // indirect
requirement that tidy wrote. Note the go@… edges as well: the Go version is
also a requirement in this graph, and the same rule applies to it (this will
come up in "Deeper").
Why it is built this way — and why that is good. The version selection is
reproducible without a lock file: the same set of go.mod files with the same
settings (replace, go.work, -mod flags) gives the same list of versions,
today and a year from now, however many new versions come out.
A requirement means "no lower than": a was written against v1.1.0 and
gets v1.3.0 — counting on minor versions being backward compatible. If the
author of c broke that compatibility, a breaks — but on an upgrade someone
made explicitly, not after somebody else's release.
Mechanism 2: go get changes requirements, go mod tidy checks them against imports
Get resolves its command-line arguments to packages at specific module versions,
updates go.mod to require those versions, and downloads source code into the
module cache.
The key part is "updates go.mod to require". go get does not pick a version
around the graph: it writes a requirement into the main module's go.mod,
and from there the same minimal version selection takes over:
Output of bench/gomod/internals.go, section 2; labels translated:
2) go get: UPGRADE, DOWNGRADE, -u
─────────────────────────────────
$ go get example.com/c@v1.5.0
go: upgraded example.com/c v1.3.0 => v1.5.0
go.mod: require example.com/c v1.5.0 // indirect
$ go list -m example.com/c
example.com/c v1.5.0
$ go run .
a.CVersion() = v1.5.0
b.CVersion() = v1.5.0
There are now three requirements on c — v1.1.0, v1.3.0 and v1.5.0 — and
the highest wins. A downgrade is more interesting. b requires c v1.3.0, and
the proxy has no other version of b that would settle for v1.1.0 (go.mod
was reset to the original, with c v1.3.0, before this step):
Output of bench/gomod/internals.go, section 2; labels translated:
a downgrade; there are no other versions of b in the proxy:
$ go get example.com/c@v1.1.0
go: removed example.com/b v1.0.0
go: downgraded example.com/c v1.3.0 => v1.1.0
go.mod: module example.com/app
go.mod: require example.com/a v1.0.0
go.mod: require example.com/c v1.1.0 // indirect
$ go run .
main.go:7:2: no required module provides package example.com/b; to add it:
go get example.com/b
[exit code 1]
go get downgraded c exactly as asked, and resolved the contradiction by
throwing b out of the requirements — with a message, but without asking.
The build broke after that. But removal is the extreme case. If the proxy has a
version of b that settles for v1.1.0, the same command downgrades b too:
Output of bench/gomod/internals.go, section 2; labels translated:
a downgrade; b v0.9.0 added to the proxy, it requires c v1.1.0:
$ go get example.com/c@v1.1.0
go: downgraded example.com/b v1.0.0 => v0.9.0
go: downgraded example.com/c v1.3.0 => v1.1.0
go.mod: module example.com/app
go.mod: example.com/a v1.0.0
go.mod: example.com/b v0.9.0
go.mod: require example.com/c v1.1.0 // indirect
$ go run .
a.CVersion() = v1.1.0
b.CVersion() = v1.1.0
A downgrade is not "install an older version" but "downgrade everyone who
requires a newer one": go get picks a version of each that settles for
v1.1.0, and if there is none, as with b in the first case, removes the
requirement.
A version above the required one comes only from explicit commands:
go get example.com/c with no version or with @latest (per go help get, it
will "upgrade it to its latest version"), and go get -u:
Output of bench/gomod/internals.go, section 2; labels translated:
$ go get -u ./...
go: upgraded example.com/c v1.3.0 => v1.9.0
go.mod: require example.com/c v1.9.0 // indirect
go get with no version and with @latest:
$ go get example.com/c
go: upgraded example.com/c v1.3.0 => v1.9.0
go.mod: require example.com/c v1.9.0 // indirect
$ go get example.com/c@latest
go: upgraded example.com/c v1.3.0 => v1.9.0
The documentation describes -u as updating the modules that provide
dependencies of the named packages to use newer minor or patch releases when
available. All three are an explicit human action, not a side effect of a
build.
go mod tidy does something else — it checks go.mod against what the code
actually imports:
It adds any missing modules necessary to build the current module's packages and
dependencies, and it removes unused modules that don't provide any relevant
packages.
Output of bench/gomod/internals.go, section 3; labels translated:
3) go mod tidy AND // indirect
──────────────────────────────
an extra requirement: example.com/unused added to go.mod, no import
$ go mod edit -require=example.com/unused@v1.0.0
(no output)
$ go mod tidy -v
unused example.com/unused
go.mod: (no lines with unused)
an import without a requirement: added file extra.go with import "example.com/unused"
$ go build -o /dev/null .
extra.go:3:8: no required module provides package example.com/unused; to add it:
go get example.com/unused
[exit code 1]
$ go mod tidy
go: finding module for package example.com/unused
go: found example.com/unused in example.com/unused v1.0.0
go.mod: example.com/unused v1.0.0
tidy removed the extra requirement. The opposite situation — an import is
there, a requirement is not — shows something important: by default
go build does not edit go.mod. It stopped and suggested a command; only
tidy wrote the requirement. A build that silently changes the list of
dependencies would not be reproducible.
"By default" matters here: without a vendor directory, go behaves as if
-mod=readonly were set. The -mod=mod flag lifts that restriction — the same
go build finds the module and writes the requirement itself:
Output of bench/gomod/internals.go, section 9; labels translated:
9) go build -mod=mod: EDITING go.mod IS ALLOWED EXPLICITLY
──────────────────────────────────────────────────────────
an import without a requirement: added file extra.go with import "example.com/unused"
$ go build -o /dev/null .
extra.go:3:8: no required module provides package example.com/unused; to add it:
go get example.com/unused
[exit code 1]
$ go build -mod=mod -o /dev/null .
go: finding module for package example.com/unused
go: found example.com/unused in example.com/unused v1.0.0
go.mod: example.com/unused v1.0.0
So the rule is more precisely this: go.mod is changed by go get,
go mod tidy, and a build with an explicit -mod=mod (the flag can also be set
through GOFLAGS).
The last part — what // indirect means:
Output of bench/gomod/internals.go, section 3; labels translated:
a direct import of c: added file extra.go with import "example.com/c"
go.mod before: require example.com/c v1.3.0 // indirect
$ go mod tidy
(no output)
go.mod after: require example.com/c v1.3.0
// indirect is the mark "no package of the main module imports this module;
the dependencies need it". As soon as app imported c itself, the mark was
gone. Why tidy writes requirements on modules that app does not import at
all is a consequence of the go line, and it shows on go.sum in the next
mechanism.
Mechanism 3: go.sum — the archive's hash and the hash of go.mod
When the go command downloads a module zip file or go.mod file into the module
cache, it computes a cryptographic hash and compares it with a known value to
verify the file hasn't changed since it was first downloaded. Known hashes are
stored in a file in the module root directory named go.sum.
The lines of go.sum come in two kinds — the hash of the module's files and the
hash of its go.mod. In the lesson's graph that came to two per module in the
build:
Output of bench/gomod/internals.go, section 4; labels translated:
4) go.sum: HASHES AND TAMPERING
───────────────────────────────
go.sum after go mod tidy:
example.com/a v1.0.0 h1:CZz7vqm6MQBHsDphHciMNMa6SlKCJcZr9XrEtYSTg/g=
example.com/a v1.0.0/go.mod h1:Cg5o19qt63F5qZnxvlazSN42WL9TH81VnebnIKrtmII=
example.com/b v1.0.0 h1:9aOSoYaMjU23gumsTjqFW+JjjeIti/u9p65uRO8SR94=
example.com/b v1.0.0/go.mod h1:nPiYYe6I4RA/VudIzX11KxjlL1GUJpo4DFPe0FX/Jvc=
example.com/c v1.3.0 h1:5VqJIdrvw5IP6QYOEa4QmH5x99fJVd78wLdNFCrrUNc=
example.com/c v1.3.0/go.mod h1:slkmCTK66XZBf3CTta1WAoTNx6XLaBNrYJ6tHb7WT0g=
lines in go.sum: 6
The line without a suffix is the hash of all the module's files (names and contents, not the zip bytes). The prefix
h1: names the hash function: base64 of the SHA-256 of a summary that has, for
each file in the archive, a line "SHA-256 of the contents and the file name"
(golang.org/x/mod/sumdb/dirhash, lines 31–41). The line with /go.mod is the
hash of go.mod alone: to build the graph, go reads the go.mod of many
versions, but downloads the archive only for those whose code goes into the
build.
Note what is not here: a line for c v1.1.0, even though the edge
a → c v1.1.0 is in the graph. In go mod graph, c@v1.1.0 had no edges of
its own either: go did not read its go.mod at all. The reason is the line
go 1.22 in module a's go.mod: since 1.17 go.mod keeps more requirements
— that is why tidy wrote the line c v1.3.0 // indirect into app — and the
graph beyond such modules is pruned: only their direct requirements enter it
(graph pruning; the main module is also at go 1.17 or higher).
The tidy documentation is terse about it: Go versions 1.17 and higher retain
more requirements in order to support lazy module loading. The same graph with
go 1.16 in the dependencies' go.mod:
Output of bench/gomod/internals.go, section 4; labels translated:
the same requirements, but a and b declare go 1.16 in go.mod (olda, oldb):
$ go mod tidy
(no output)
go.sum lines about example.com/c:
example.com/c v1.1.0/go.mod h1:slkmCTK66XZBf3CTta1WAoTNx6XLaBNrYJ6tHb7WT0g=
example.com/c v1.3.0 h1:5VqJIdrvw5IP6QYOEa4QmH5x99fJVd78wLdNFCrrUNc=
example.com/c v1.3.0/go.mod h1:slkmCTK66XZBf3CTta1WAoTNx6XLaBNrYJ6tHb7WT0g=
lines in go.sum: 7
Now the go.mod of the unselected v1.1.0 has been read and hashed: a graph
before 1.17 is loaded in full. And a detail along the way: the /go.mod hashes
of v1.1.0 and v1.3.0 are the same — these versions have the same
go.mod, and only it is hashed.
Hence: the number of lines in go.sum is not an invariant. A module may have
only a /go.mod line, one module may have lines for several versions, and after
commands that do not clean the file there may be extra entries: removing
"unnecessary ones" is the job of go mod tidy. An extra line in go.sum is not
a sign of an error.
What go does with these hashes. Without go.sum the build does not proceed:
Output of bench/gomod/internals.go, section 4; labels translated:
without go.sum:
$ go build -o /dev/null .
main.go:6:2: missing go.sum entry for module providing package example.com/a (imported by example.com/app); to add:
go get example.com/app
And if the contents of a version in the proxy are replaced — the same number
v1.3.0, different code — and the module is built on a clean cache:
Output of bench/gomod/internals.go, section 4; labels translated:
the contents of c v1.3.0 replaced in the proxy (same version, different code), go.sum unchanged:
$ go build -o /dev/null .
verifying example.com/c@v1.3.0: checksum mismatch
downloaded: h1:sT/G26SeVFHyNJDFxu1SduC9eDWsMDygdoosW/dx/bQ=
go.sum: h1:5VqJIdrvw5IP6QYOEa4QmH5x99fJVd78wLdNFCrrUNc=
SECURITY ERROR
This download does NOT match an earlier download recorded in go.sum.
The bits may have been replaced on the origin server, or an attacker may
have intercepted the download attempt.
For more information, see 'go help module-auth'.
[exit code 1]
The build stopped. In exactly the same way — checksum mismatch and
SECURITY ERROR — go answered when one character of a hash in go.sum was
corrupted, both with a warm module cache and with an empty one. Hence the
practical rule: go.sum is committed together with go.mod. It does not
choose versions — go.mod does that — it guarantees that everyone gets the same
code under a version number.
Mechanism 4: v2 and above — a different import path
A new major version, by semantic versioning, is the right to break
compatibility. Modules carry this rule into the path: starting with v2, the
module path ends in /v2, /v3 and so on (the exceptions are gopkg.in paths
with a .vN suffix and +incompatible versions of modules without a go.mod;
the run did not check them). The source of the path-and-version
check states the rule by example: the path "yaml/v2" only corresponds to
semantic versions beginning with "v2." So example.com/c and
example.com/c/v2 are different modules, and they legitimately live in one
build:
Output of bench/gomod/internals.go, section 5; labels translated:
5) /v2: TWO MAJOR VERSIONS IN ONE BUILD
───────────────────────────────────────
main.go imports "example.com/c" and cv2 "example.com/c/v2"
$ go mod tidy
go: finding module for package example.com/c/v2
go: finding module for package example.com/c
go: found example.com/c in example.com/c v1.9.0
go: found example.com/c/v2 in example.com/c/v2 v2.0.0
$ go list -m all
example.com/two
example.com/c v1.9.0
example.com/c/v2 v2.0.0
$ go run .
c.Version() = v1.9.0
cv2.Version() = v2.0.0
$ go list -m -versions example.com/c example.com/c/v2
example.com/c v1.1.0 v1.3.0 v1.5.0 v1.9.0
example.com/c/v2 v2.0.0
Each module has its own list of versions; minimal version selection works within
one path and does not compare major versions at all. That is the point of the
rule: if v2 lived under the old path, a requirement on c v2.0.0 from one
library would lift another library, which needs v1, to an incompatible version.
Different paths make such a build possible.
And along the way: for a new import that is not yet in the graph, tidy took
the latest version — v1.9.0. Minimal version selection resolves
requirements; when there is no requirement at all, one is created from the
latest version in the proxy, and from then on it is fixed in go.mod.
A requirement on v2 under a path without /v2 is rejected by go already
while parsing go.mod:
Output of bench/gomod/internals.go, section 5; labels translated:
v2 under a path without /v2 — the requirement added to go.mod by hand:
$ go mod edit -require=example.com/c@v2.0.0
(no output)
$ go build -o /dev/null .
go: errors parsing go.mod:
go.mod:6:2: require example.com/c: version "v2.0.0" invalid: should be v0 or v1, not v2
[exit code 1]
For a library author this means: releasing v2 is a new path in module and in
all internal imports, and users move to it by changing their imports. That is
expensive — and intentionally so.
Mechanism 5: replace — swapping the source, not the version
replace says: "take this module's code from here". Most often from a local
folder, when a change in a dependency needs checking without releasing a
version:
Output of bench/gomod/internals.go, section 6; labels translated:
6) replace: A LOCAL FOLDER
──────────────────────────
$ go mod edit -replace=example.com/c=../c-local
(no output)
go.mod: replace example.com/c => ../c-local
$ go list -m example.com/c
example.com/c v1.3.0 => ../c-local
$ go run .
a.CVersion() = local copy
b.CVersion() = local copy
$ go mod tidy
(no output)
go.sum lines about example.com/c after tidy:
(no lines with example.com/c)
In this example the selected version did not change — v1.3.0, as without
replace — but the code of c for both modules, a and b, was taken from
the folder. The whole source is replaced — and with it the replacement's
go.mod with its own requirements (c-local has none; a case where it does
follows below). No lines about c remained in go.sum: a local folder has no
version that could be downloaded and hashed.
The main limit of replace is that it works only in the main modules: in
the main module, and in a go.work workspace in each of its modules and in
go.work itself. In an ordinary dependency's go.mod it does not work. In the
proxy, module b's go.mod looks like this:
Output of bench/gomod/internals.go, section 6; labels translated:
replace in a dependency's go.mod: the go.mod of module b from the proxy —
module example.com/b
go 1.22
require example.com/c v1.3.0
replace example.com/c => ../c-fork
the folder ../c-fork exists nowhere, and that is not an error:
$ go run .
a.CVersion() = v1.3.0
b.CVersion() = v1.3.0
The replacement in a dependency's go.mod was ignored entirely — even a folder
that does not exist led to nothing.
In the main module the same missing folder is a build error:
Output of bench/gomod/internals.go, section 6; labels translated:
replace in the main module to a folder that does not exist:
$ go mod edit -replace=example.com/c=../c-missing
(no output)
$ go build -o /dev/null .
../modcache/example.com/a@v1.0.0/a.go:3:8: example.com/c@v1.3.0: replacement directory ../c-missing does not exist
[exit code 1]
A workspace changes the picture. A go.work file lists module directories, and
per go help environment the modules specified will collectively be used as
the main modules. Put a copy of b next to app — the directory b-dev with
the same replace example.com/c => ../c-local in its go.mod — and build app
through go.work:
Output of bench/gomod/internals.go, section 10; labels translated:
10) replace: A WORKSPACE AND THE REPLACEMENT'S REQUIREMENTS
───────────────────────────────────────────────────────────
go.work: use ./app and ./b-dev; b-dev is a copy of b with replace example.com/c => ../c-local
$ GOWORK=<tmp>/go.work go list -m example.com/b example.com/c
example.com/b
example.com/c v1.3.0 => ./c-local
$ GOWORK=<tmp>/go.work go run .
a.CVersion() = local copy
b.CVersion() = local copy
without go.work (GOWORK=off) — the same app:
$ go run .
a.CVersion() = v1.3.0
b.CVersion() = v1.3.0
b became a main module of the workspace (it has no version in the list), and
its replace took effect for the whole build. Without go.work the same line
in b's go.mod again means nothing.
And a second consequence of replacing the whole source: the replacement may
have its own go.mod with its own requirements, and they enter the graph. The
directory c-req declares the module example.com/c and requires
example.com/unused:
Output of bench/gomod/internals.go, section 10; labels translated:
a replacement with its own requirements: the go.mod of directory c-req requires example.com/unused
$ go list -m all
example.com/app
example.com/a v1.0.0
example.com/b v1.0.0
example.com/c v1.3.0
$ go mod edit -replace=example.com/c=../c-req
(no output)
$ go mod tidy
(no output)
$ go list -m all
example.com/app
example.com/a v1.0.0
example.com/b v1.0.0
example.com/c v1.3.0 => ../c-req
example.com/unused v1.0.0
$ go run .
a.CVersion() = c-req + unused
b.CVersion() = c-req + unused
The selected version of c is v1.3.0 here too, but a module appeared in the
build list that nobody required except the replacement. replace swaps not
"a version for a version" but the source together with its requirements.
Hence two practical conclusions. For a library author: a replace in your
go.mod will not fix your users' build — they will have to write the same one
themselves. For an application: a replace to ../folder in a committed
go.mod breaks the build for everyone who does not have that folder — with
exactly this error — so it is removed before committing.
Deeper: the go line — the language version and the minimal toolchain
The go line in go.mod has two roles, and they are best not confused.
The first is the language version. The specification ties features to
language versions: range over an integer is marked Go 1.22. And the rule for
such marks is general — source code will be rejected if the implied or required
language version used by the compiler is older than the version needed. A
module's language version is its go line:
Output of bench/gomod/internals.go, section 7; labels translated:
7) THE go AND toolchain LINES
─────────────────────────────
for i := range 3, go 1.21 in go.mod:
$ go run .
# example.com/lang
./main.go:6:17: cannot range over 3 (untyped int constant): requires go1.22 or later (-lang was set to go1.21; check go.mod)
[exit code 1]
for i := range 3, go 1.22 in go.mod:
$ go run .
012
The compiler is the same — go1.24.7 — and the outcome is decided by a line in
go.mod. In the same way the go line switches the semantics of the loop
variable: in a module with go 1.21 all parallel subtests that close over the loop
variable see the last case of the table — that is covered in the lesson on testing.
The second role is the lowest Go version the module may be built with. What
happens when it is newer than the installed one is decided by GOTOOLCHAIN:
Output of bench/gomod/internals.go, section 7; labels translated:
go 1.25.0 in go.mod:
$ GOTOOLCHAIN=local go build -o /dev/null .
go: go.mod requires go >= 1.25.0 (running go 1.24.7; GOTOOLCHAIN=local)
[exit code 1]
$ GOTOOLCHAIN=auto go build -o /dev/null .
go: download go1.25.0 for linux/amd64: toolchain not available
[exit code 1]
With local, go1.24.7 refused to build a module that needs 1.25.0. With
auto — the default from the distribution's go.env file, with the comment
"Automatically download newer toolchains as directed by go.mod files" — it tried
to download go1.25.0: a toolchain is downloaded as the module
golang.org/toolchain through the same GOPROXY
(cmd/go/internal/toolchain/select.go, lines 358–372 in go1.24.7), and the
run's file proxy does not have it. On go1.25.1 both commands passed with no
output.
The toolchain line is no longer a requirement but a preference: which
toolchain to use when the choice is up to go:
Output of bench/gomod/internals.go, section 7; labels translated:
go 1.24 and toolchain go1.25.1 in go.mod:
$ GOTOOLCHAIN=local go env GOVERSION
go1.24.7
$ GOTOOLCHAIN=auto go env GOVERSION
go: download go1.25.1 for linux/amd64: toolchain not available
[exit code 1]
With local the toolchain line was ignored: the module requires only
go 1.24, and go1.24.7 builds it. With auto, go tried to switch to
go1.25.1.
And a last point, where the link to Mechanism 1 shows. A requirement on a Go
version propagates through the graph like a requirement on a module: a
dependency with go 1.25.0 in its go.mod raises your go line. On go1.24.7
with GOTOOLCHAIN=local, go get refused:
Output of bench/gomod/internals.go, section 7; labels translated:
a dependency whose go.mod requires go 1.25.0:
$ GOTOOLCHAIN=local go get example.com/fresh@v1.0.0
go: example.com/fresh@v1.0.0 requires go >= 1.25.0 (running go 1.24.7; GOTOOLCHAIN=local)
[exit code 1]
And on go1.25.1 the same command, without asking anything, rewrote the main
module's go line:
Output of the same section 7 on go1.25.1 (runs/internals-go1.25.txt); labels translated:
$ GOTOOLCHAIN=local go get example.com/fresh@v1.0.0
go: upgraded go 1.24 => 1.25.0
go: added example.com/fresh v1.0.0
go.mod: go 1.25.0
After that the module can be built only by Go 1.25 and newer — by everyone,
CI included. That is why a change of the go line gets the same attention in
review as a new dependency.
How to answer in an interview
Short answer: a module is a set of packages released under one version; the
go.mod at its root lists requirements — a module and a version "no lower than"
— and go.sum stores the hashes of the archive and of go.mod for each module
needed. The version is chosen by minimal version selection: for each module, the
highest of those required in the graph, not the latest in the proxy.
That is enough to answer correctly. What follows is what you add if the interviewer digs.
If the interviewer digs deeper
On "two libraries require different versions" — an example with numbers.
"a requires c v1.1.0, b requires v1.3.0, the proxy has v1.9.0.
v1.3.0 is selected, and a works with it too: there is one copy. v1.9.0
does not get in until someone requires it — for instance, go get -u."
On "go get versus go mod tidy." "go get adds or changes a requirement
and recomputes the graph; on a downgrade, modules that need a higher version are
downgraded too, and removed if there is nowhere to go. tidy checks go.mod against the imports: it removes what is extra,
adds what is missing, sets // indirect. By default go build does not edit
go.mod — only with an explicit -mod=mod."
On "why go.sum." "So that everyone gets the same code under a version
number. Lines of two kinds — the hash of the module's files and the hash of its
go.mod; how many depends on the graph. Replacing the contents
of the same version gives checksum mismatch and SECURITY ERROR. It is
committed together with go.mod."
On "why /v2." "A major version is a different module with a different
path. Otherwise picking the highest version would lift a dependency across an
incompatible boundary. c and c/v2 legitimately live in one build."
On replace and the go line. "replace swaps the source — together with
the requirements from its go.mod — and works only in the main modules: the
main module or the modules of a go.work workspace. The go line is the language version for that
module and the lowest Go version for the build; with GOTOOLCHAIN=auto, go
will try to download a newer one."
Next they ask
How does go.sum differ from a lock file — package-lock.json or poetry.lock?
A lock file in npm or Poetry pins the choice: without it, resolving version
ranges may give other versions. go.sum chooses nothing — the choice follows
unambiguously from the go.mod files of the whole graph and, with the same
settings, is reproducible anyway. go.sum only checks that the code under the chosen numbers is the
same as it was.
The run shows it: without go.sum, go did not pick "some" versions, it
refused to build — missing go.sum entry. And go.sum after tidy holds
exactly the selected versions, and for c only v1.3.0, if the dependencies
declare go 1.17 or higher; with go 1.16 the go.mod hash of the unselected
v1.1.0 was added as well.
How do you downgrade a dependency if another library requires it?
Only together with that library. b's requirement "no lower than v1.3.0"
does not go anywhere. go get example.com/c@v1.1.0 downgrades b itself if the
proxy has a version of it that is fine with v1.1.0: in the run with
b v0.9.0 — go: downgraded example.com/b v1.0.0 => v0.9.0. If there is no
such version, b is removed: go: removed example.com/b v1.0.0, and the build
does not compile.
If the library cannot be downgraded, what remains is to swap the code of c
with replace (Mechanism 5): b's requirement on v1.3.0 stays, and the code
comes from wherever you say.
Why does go build not add a missing dependency by itself?
Because a build should not change the list of dependencies. In the run,
go build stopped on an import without a requirement:
no required module provides package example.com/unused; to add it — and
suggested go get. Only go mod tidy wrote the requirement.
If go build silently added dependencies, the same command in CI and on a
laptop could build different things. Without a vendor directory, per
go help build, the go command acts as if -mod=readonly were set: go.mod and
go.sum are changed only by the commands that exist for that. This is the
default, not a prohibition: with go build -mod=mod the run found the module
and wrote the requirement itself (section 9).
How do you develop two modules at once without releasing versions?
A temporary replace to a local folder — what Mechanism 5 shows: go list -m
prints v1.3.0 => ../c-local, and both dependent modules take the code from the
folder. It is removed before committing: others do not have the folder.
For the same case there is the go.work file — a workspace. Per
go help environment, if one is found, the modules specified will collectively
be used as the main modules — which means their replace directives and their
code all apply at once. That is what happened in the run: b-dev in the
workspace replaced b, and its replace sent c to ../c-local
(Mechanism 5).
Common misconceptions
go takes the latest version of a dependency
For a dependency already in the graph — the highest of the required ones. In the run the proxy held v1.9.0, and v1.3.0 was selected: b requires it, and nobody requires v1.9.0. The latest version is taken only by explicit commands — go get module or @latest, go get -u — and by tidy for a new import with no requirement.
Each library has its own copy of the dependency at the version it needs
There is one copy per module (per major version). a required c v1.1.0 and got v1.3.0 — the same one as b: a.CVersion() = v1.3.0.
go.mod records the versions installed in the project
go.mod records lower bounds. Which version gets into the build is shown by go list -m all: a's requirement on c v1.1.0 is in the graph, while the build has v1.3.0.
go.sum is a lock file, and it need not be committed
It does not choose versions, but without it the build does not proceed: missing go.sum entry for module providing package. And it is what catches tampering: an archive of the same version with different code gave checksum mismatch and SECURITY ERROR.
go build will download and add a missing dependency by itself
Not by default: no required module provides package example.com/unused; to add it. go get and go mod tidy add it — or the build itself, if -mod=mod is set explicitly: then go build found the module and wrote the requirement.
Two major versions of one library in one build are a conflict
example.com/c and example.com/c/v2 are different modules with different paths. In the run one program printed both v1.9.0 and v2.0.0.
A replace in a library's go.mod will fix the dependency for all its users
replace works only in the main modules — the main module or the modules of a go.work workspace. Module b's go.mod, as an ordinary dependency, had a replace to a folder that does not exist — and the build of app did not notice.
The go line in go.mod is just for information
It is the module's language version and the lowest Go version for the build. With go 1.21, for i := range 3 did not compile — requires go1.22 or later; with go 1.25.0 and GOTOOLCHAIN=local, go1.24.7 refused to build the module at all.
Practice
Two tasks. Answer first, then check against the real output: in both, the right answer is taken from the script's run, not assigned.
Practice · predict the output
// go.mod of module x
module example.com/x
go 1.22
require (
example.com/z v1.2.0
example.com/w v1.0.0
)
// go.mod of module y
module example.com/y
go 1.22
require example.com/z v1.4.0
// the proxy has z v1.2.0, v1.4.0 and v1.7.0
// main.go of the app
fmt.Println("x:", x.ZVersion())
fmt.Println("y:", y.ZVersion())Practice · estimate
Knowledge check
a requires c v1.1.0, b requires c v1.3.0, and the proxy has c v1.9.0. Which version of c gets into the build of an app that requires a and b?
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- A module is a set of packages released under one version, and the
go.modat its root is a list of requirements: the path of each dependency and the lowest version the module agrees to. The version that ends up in the build is not named bygo.mod— thegocommand computes it over the whole requirement graph. There is no separate lock file, and none is needed: the same graph with the same settings (replace,go.work,-modflags) gives the same list of selected versions. That is a promise about versions, not about an identical binary: the binary also depends on the toolchain, build flags and platform. - Out of several requirements the highest one is selected — and not a version more.
arequiresc v1.1.0,brequiresc v1.3.0, and the proxy also holdsv1.9.0.go list -m allshowsexample.com/c v1.3.0, anda, which asked forv1.1.0, also getsv1.3.0: a build has one copy per module. A new version in the proxy does not change the build — only a new requirement does, andgo getandgo mod tidywrite it. - Beyond that comes what separates real understanding from having read about it. The lines of
go.sumcome in two kinds — the hash of the module's files and the hash of itsgo.mod; how many there are depends on the graph, and the count follows no rule.gorefuses to build an archive of the same version replaced in the proxy, withSECURITY ERROR. Downgradingctov1.1.0also downgradesb, which needsv1.3.0, and when there is no suitable version ofbit throwsbout:removed example.com/b.v2is a different import path, andcandc/v2legitimately live in one build.replaceworks only in the main modules — in the main module, and in ago.workworkspace in each of its modules and ingo.workitself; in an ordinary dependency'sgo.modit silently does nothing. It replaces the source — and with it the requirements from the replacement'sgo.mod. And the linego 1.21does not letfor i := range 3compile — it sets the language version — whilego 1.25.0withGOTOOLCHAIN=localdoes not let go1.24.7 build the module at all.
In fact
- For a dependency already in the graph — the highest of the required ones. In the run the proxy held
v1.9.0, andv1.3.0was selected:brequires it, and nobody requiresv1.9.0. The latest version is taken only by explicit commands —go get moduleor@latest,go get -u— and bytidyfor a new import with no requirement. - There is one copy per module (per major version).
arequiredc v1.1.0and gotv1.3.0— the same one asb:a.CVersion() = v1.3.0. go.modrecords lower bounds. Which version gets into the build is shown bygo list -m all:a's requirement onc v1.1.0is in the graph, while the build hasv1.3.0.- It does not choose versions, but without it the build does not proceed:
missing go.sum entry for module providing package. And it is what catches tampering: an archive of the same version with different code gavechecksum mismatchandSECURITY ERROR. - Not by default:
no required module provides package example.com/unused; to add it.go getandgo mod tidyadd it — or the build itself, if-mod=modis set explicitly: thengo buildfound the module and wrote the requirement. example.com/candexample.com/c/v2are different modules with different paths. In the run one program printed bothv1.9.0andv2.0.0.replaceworks only in the main modules — the main module or the modules of ago.workworkspace. Moduleb'sgo.mod, as an ordinary dependency, had areplaceto a folder that does not exist — and the build ofappdid not notice.- It is the module's language version and the lowest Go version for the build. With
go 1.21,for i := range 3did not compile —requires go1.22 or later; withgo 1.25.0andGOTOOLCHAIN=local, go1.24.7 refused to build the module at all.
What is covered
- What is really being asked
- Base: a module is a tree of packages with `go.mod` at its root
- Mechanism 1: minimal version selection — the highest of the required
- Mechanism 2: `go get` changes requirements, `go mod tidy` checks them against imports
- Mechanism 3: `go.sum` — the archive's hash and the hash of `go.mod`
- Mechanism 4: `v2` and above — a different import path
- Mechanism 5: `replace` — swapping the source, not the version
- Deeper: the `go` line — the language version and the minimal toolchain
- How to answer in an interview
- Next they ask
- Common misconceptions
- Practice
- Knowledge check
Sources & further reading
12 SOURCES
- The go command — go help modules and go help go.modOfficial documentation. What a module is: «A module is a collection of packages that are released, versioned, and distributed together». Where its boundary lies: «A module version is defined by a tree of source files, with a go.mod file in its root».https://pkg.go.dev/cmd/go#hdr-Modules__module_versions__and_more
- The go command — go help get and go help mod tidyOfficial documentation. What go get does: «Get resolves its command-line arguments to packages at specific module versions, updates go.mod to require those versions, and downloads source code into the module cache». What tidy does: «It adds any missing modules necessary to build the current module's packages and dependencies, and it removes unused modules that don't provide any relevant packages. It also adds any missing entries to go.sum and removes any unnecessary ones». And about the version in the go line: «Go versions 1.17 and higher retain more requirements in order to support lazy module loading».https://pkg.go.dev/cmd/go#hdr-Add_missing_and_remove_unused_modules
- The go command — go help module-authOfficial documentation. Why go.sum: «When the go command downloads a module zip file or go.mod file into the module cache, it computes a cryptographic hash and compares it with a known value to verify the file hasn't changed since it was first downloaded. Known hashes are stored in a file in the module root directory named go.sum».https://pkg.go.dev/cmd/go#hdr-Module_authentication_using_go_sum
- The go command — go help goproxyOfficial documentation. Why the lesson's run needs no network: «The requests have no query parameters, so even a site serving from a fixed file system (including a file:/// URL) can be a module proxy».https://pkg.go.dev/cmd/go#hdr-Module_proxy_protocol
- cmd/go/internal/mvs/mvs.go — BuildListGo source code. Lines 83–84 in go1.24.7, the comment on the function that builds the build list: «BuildList traverses the graph and returns a list containing the highest version for each visited module». The package is named for it: «Package mvs implements Minimal Version Selection», line 5.https://github.com/golang/go/blob/go1.24.7/src/cmd/go/internal/mvs/mvs.go#L74-L91
- golang.org/x/mod/sumdb/dirhash/hash.go — Hash1Go source code. Lines 31–41 (the copy in src/cmd/vendor in go1.24.7) — what the h1: prefix in go.sum is: «Hash1 is "h1:" followed by the base64-encoded SHA-256 hash of a summary prepared as if by the Unix command: sha256sum $(find . -type f | sort) | sha256sum».https://github.com/golang/go/blob/go1.24.7/src/cmd/vendor/golang.org/x/mod/sumdb/dirhash/hash.go#L31-L41
- The go command — go help build, the -mod flagOfficial documentation. Why go build does not edit go.mod: «Otherwise, the go command acts as if -mod=readonly were set» — «otherwise» meaning «when there is no vendor directory».https://pkg.go.dev/cmd/go#hdr-Compile_packages_and_dependencies
- The go.env file of the go1.24.7 distributionOfficial documentation. The defaults: GOPROXY=https://proxy.golang.org,direct, GOSUMDB=sum.golang.org and GOTOOLCHAIN=auto with the comment «Automatically download newer toolchains as directed by go.mod files».https://github.com/golang/go/blob/go1.24.7/go.env
- golang.org/x/mod/module/module.go — CheckGo source code. Lines 210–215 (the copy in src/cmd/vendor in go1.24.7): a module's path and version must correspond to each other: «For example, the path "yaml/v2" only corresponds to semantic versions beginning with "v2."».https://github.com/golang/go/blob/go1.24.7/src/cmd/vendor/golang.org/x/mod/module/module.go#L210-L215
- cmd/go/internal/modload/modfile.go — replacementFromGo source code. Lines 347–372 in go1.24.7: replacements are looked up in the go.work file and in the indexes of the main modules — the loop
for _, v := range MainModules.Versions()on line 358. The go.mod files of dependencies are not read here at all; the run shows the consequence: the replace in module b's go.mod did not take effect.https://github.com/golang/go/blob/go1.24.7/src/cmd/go/internal/modload/modfile.go#L347-L372 - cmd/go/internal/toolchain/select.go — downloading a toolchainGo source code. Lines 346 and 358–372 in go1.24.7: «GOTOOLCHAIN=auto looks in PATH and then falls back to download»; the toolchain is downloaded as the module
golang.org/toolchainat versionv0.0.1-<toolchain>.<GOOS>-<GOARCH>(the constants on lines 46–47) — through the same GOPROXY. If the module is missing, the error on line 372: «toolchain not available».https://github.com/golang/go/blob/go1.24.7/src/cmd/go/internal/toolchain/select.go#L346-L375 - The Go specification — Language versionsOfficial documentation. What a language version means: «Source code containing an integer literal such as 0b1011 will be rejected if the implied or required language version used by the compiler is older than Go 1.13». And the entry for Go 1.22: «A "for" statement with "range" clause may iterate over integer values from zero to an upper limit».https://go.dev/ref/spec#Language_versions