Deep Engineering
Intermediate·Published·60 MIN

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.

Where to start
Before this lesson it is enough to understand
  • what a package and an import path are — that is the lesson on packages;
  • how to run go build and go run;
  • that a version like v1.3.0 is made of the major, minor and patch parts of semantic versioning: minor and patch promise compatibility, major does not (in detail — Mechanism 4).
You do not need to know in advance
  • minimal version selection, go get, go mod tidy and // indirect;
  • what lies in go.sum and what go does when a hash does not match; the /v2 import;
  • replace, the go and toolchain lines, GOTOOLCHAIN.

What is really being asked

The ladder is almost always this one:

  1. "What is a module, and how does it differ from a package?" — the warm-up.
  2. "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.
  3. "What does go get do, what does go mod tidy do, and what does // indirect mean?" — this checks whether the candidate understands that go.mod is written by commands, not only by hand.
  4. "Why go.sum? Should it be committed?" — about integrity checking.
  5. "Why does the second major version have /v2 in its path?" — about semantic import versioning.
  6. "How do you swap a dependency for a local copy?" — replace and its limits.
  7. "What does the line go 1.22 in go.mod mean?" — 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.

— go help modules
language contractWhat a module, go.mod and go.sum are, and what go get and go mod tidy do, is the documentation of the go command (go help modules, go.mod, get, mod tidy, module-auth). The run of bench/gomod/internals.go illustrates it rather than establishes it.

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

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

filewhat it holdswho writes it
go.modrequirements "module, no lower than version"; go, toolchain, replaceyou, go get, go mod tidy
go.sumh1: 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 cacheunpacked sources, go env GOMODCACHEgo, 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.0 is 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 c in the build, and a, which asked for v1.1.0, works with v1.3.0. Two versions of one module in one build do not happen (except for different major versions — that is Mechanism 4).
implementation detail · Go 1.24The algorithm is the package cmd/go/internal/mvs, the comment on BuildList on lines 83–84 in go1.24.7. The behaviour itself — the highest version among the required ones, not the highest available — is shown by the run, sections 1 and 2.

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.

— go help get

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.

— go help mod tidy

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.

— go help module-auth

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.

language contractThe run used GOSUMDB=off: without a network there is no checksum database. In the usual setup (the distribution's go.env: GOSUMDB=sum.golang.org) the first download of a module not yet in go.sum is checked against it as well — go help module-auth: «Hashes may also be downloaded from the checksum database depending on the values of GOSUMDB, GOPRIVATE, and GONOSUMDB». The check against go.sum shown above does not depend on GOSUMDB.

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.

implementation detail · Go 1.24cmd/go/internal/modload/modfile.go, the function replacementFrom, lines 347–372 in go1.24.7: replacements are taken from go.work and from the go.mod of the main modules (the loop over MainModules.Versions() on line 358). The run shows the consequence.

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

measured observationbench/gomod/internals.go, section 7, on go1.24.7 and go1.25.1 linux/amd64. No toolchains were downloaded: the run's proxy is a file proxy, and it has no golang.org/toolchain module.

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

Next they ask

How does go.sum differ from a lock file — package-lock.json or poetry.lock?

Short answer

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.

Next they ask

How do you downgrade a dependency if another library requires it?

Short answer

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.

Next they ask

Why does go build not add a missing dependency by itself?

Short answer

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

Next they ask

How do you develop two modules at once without releasing versions?

Short answer

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

Claim

go takes the latest version of a dependency

Actually

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.

Claim

Each library has its own copy of the dependency at the version it needs

Actually

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.

Claim

go.mod records the versions installed in the project

Actually

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.

Claim

go.sum is a lock file, and it need not be committed

Actually

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.

Claim

go build will download and add a missing dependency by itself

Actually

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.

Claim

Two major versions of one library in one build are a conflict

Actually

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.

Claim

A replace in a library's go.mod will fix the dependency for all its users

Actually

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.

Claim

The go line in go.mod is just for information

Actually

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

The app requires x v1.0.0 and y v1.0.0. x requires z v1.2.0 (and w v1.0.0), y requires z v1.4.0; the proxy has z v1.2.0, v1.4.0 and v1.7.0. After go mod tidy, the program is run, and then go list -m example.com/z. The code below is fragments of go.mod and main.go; the full script that builds this graph and prints the answer is bench/gomod/practice.go. What is printed?
// 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

The same graph: the app → x and y; x requires z v1.2.0 and w v1.0.0, y requires z v1.4.0; every dependency's go.mod declares go 1.22. How many lines will go.sum have after go mod tidy?
lines

Knowledge check

Question 1 of 5

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?

Sources & further reading

12 SOURCES

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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
  7. 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
  8. 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
  9. 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
  10. 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
  11. 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/toolchain at version v0.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
  12. 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