Skip to content
intermediate Phase 7 · Packages, Modules, and Project Layout

go.mod and Semantic Versioning

Declare module requirements, verify go.sum hashes, and version major releases.

45m
0 problems
Topic Progress 0%

The Dependency Graph Has an Election, and the Highest Version Wins

Your service requires github.com/acme/logging v1.2.0. Today a new release of your telemetry library arrives, and it requires github.com/acme/logging v1.3.0. You did not ask for the upgrade, but next build your service runs v1.3.0. Go's answer to "which version do we use when two modules disagree" is written in a ten-line file:

module example.com/app

go 1.22

require (
	github.com/acme/logging v1.3.0
	github.com/acme/telemetry v1.10.0
)

Each directive does one job. module is the import path root — every package in this repo lives under example.com/app/.... go sets the language version and the module-graph semantics the toolchain applies (it does not pin your installed compiler). require lists versions that are part of the build.

The selection rule is minimal version selection: treat every require in every module of the graph as a floor, then pick the maximum of all floors. Nothing is ever downgraded below what someone requested, and nothing is upgraded beyond what was requested — the selected version is the highest one any participant asked for, no more and no less.

Walk your case: your floor for logging is v1.2.0, telemetry's floor is v1.3.0, the maximum is v1.3.0, so v1.3.0 is selected and recorded in your go.mod. A third dependency requiring logging v1.1.0 changes nothing — lower floors cannot win. Notice what MVS does not do: it never picks the latest version on the network. If nothing requires a newer version than v1.3.0, your build stays on v1.3.0 forever, which is how Go builds stay reproducible without a separate lockfile.

When you want to move the floor yourself, say so explicitly:

go get github.com/acme/logging@v1.4.0   # raise our floor to v1.4.0
go get github.com/acme/logging@v1.2.0   # try to lower it...

The second command cannot actually downgrade if telemetry still requires v1.3.0 — floors are combined, not overridden. To force an older version you must remove the higher requirement (fork, pin, or wait for the dependency to release). Understanding that floors compose explains most surprises in go list -m all output.

v2 Means a New Import Path: Go's SemVer Rules

Semantic versioning says the major digit moves when you break compatibility. Go takes that further than most ecosystems: a major version of 2 or higher must change the module path itself by appending /v2:

// repo example.com/lib has released v2.0.0
module example.com/lib/v2

Consumers import example.com/lib/v2/worker, not example.com/lib/worker. This one rule makes the ugliest migration problem in other ecosystems disappear: v1 and v2 packages can live in the same binary, because they are literally different import paths. A program can migrate one package at a time, running old and new versions side by side. The price is a mechanical change — every import line of the library gets the suffix — which is exactly the friction a breaking change deserves.

The digit-rolling rules:

  • v1.x.y — additive changes (new exported function, new field) are minor; bug fixes that keep documented behavior are patch; removing or renaming an exported identifier is breaking.
  • v2.0.0 — any breaking change, module path gains /v2.
  • v2.3.4+incompatible — a legacy tag on a repository with no go.mod. Usable, but fragile: one major line per path, so a v1 module-aware dependency and a v2 +incompatible one cannot coexist cleanly. Give the repository a go.mod before the first v2 release.
  • v1.4.0-rc.1 — pre-release. go get pkg skips pre-releases; you must ask for one by full name, so testing candidates never hijack default builds.
  • Untagged commits appear as pseudo-versions built from the last tag plus date and hash — v1.3.1-0.20260401120000-9f3c1a2b4d5e — which lets you pin a fix before it is released:
go get github.com/acme/lib@9f3c1a2b4d5e   # build the commit directly

If you ship a broken release, retract warns consumers without rewriting history:

retract v1.3.1 // panics on empty input; use v1.3.2

A retracted version still exists for anyone who pinned it explicitly, but the toolchain reports the retraction on selection. Retractions live in your go.mod, travel with the module, and are read by everyone who requires you.

go.sum: The Ledger That Keeps Tags Honest

Tags are mutable: anyone with push access can move v1.3.0 to a different commit, and a build that trusts the network blindly would silently change. Go's defense is go.sum, a committed ledger of hashes:

github.com/acme/logging v1.3.0 h1:Wq2jKv8...
github.com/acme/logging v1.3.0/go.mod h1:8xMmRc...
github.com/acme/logging v1.2.0/go.mod h1:pQd3f...

Two kinds of entry matter. /go.mod lines hash the requirement files of every version the module graph considered — including versions that lost MVS selection — because the graph is consulted before selection finishes. The h1: lines hash the source archive of versions whose packages were actually built. That asymmetry explains a common confusion: go.sum contains versions you are not using, and deleting them breaks the next graph computation.

Verification happens on download: content whose hash is absent from go.sum is rejected unless the toolchain is adding the entry under your control. go mod verify re-checks everything already in the cache against the ledger, catching a corrupted or tampered cache on a long-lived CI machine:

go mod verify
# all modules verified

When a build fails with missing go.sum entry for go.mod/file, the story is always the same: go.mod changed without its hash records. go mod tidy (or go get) regenerates them, and both files go in the same commit. You never write a hash by hand — they are computed from content, and a wrong one fails verification rather than passing quietly.

One design note worth carrying into interviews: go.sum is not a lockfile choosing versions. Versions come from MVS in go.mod; go.sum only proves the content matches what was already decided. Reproducibility has two halves — which version (requirements plus MVS) and which bytes (go.sum) — and conflating them is the most common interview stumble on this topic. Delete go.sum and the next tidy regenerates it from verified downloads; hand-edit it and verification fails loudly. Treat it as a signed manifest that travels with every commit: review it, commit it, and never prune entries you think are unused.

Shipping a Release Without Breaking Strangers

A library with downstream consumers follows a boring, correct release loop. Merge the change, decide the digit honestly, tag, and let the module proxy do the rest:

git tag v1.5.0
git push origin v1.5.0
# consumers upgrade deliberately:
go get example.com/lib@v1.5.0
go mod tidy

The digit decision is the whole job. Added an exported function or field? v1.6.0 — consumers pick it up whenever they choose, and nothing existing breaks. Fixed a bug without changing documented behavior? v1.5.1. Removed a field, changed a signature, altered what an existing function returns? That is v2: module example.com/lib/v2, tag v2.0.0, and consumers migrate on their own schedule because the import paths keep v1 and v2 apart in their go.mod.

For repositories with modules in subdirectories, the tag must carry the module's path below the repo root — a module at example.com/repo/tools/migrate tags as tools/migrate/v1.4.0. Miss this and go get reports "unknown revision" while your release exists.

Consumers manage their own upgrades with two commands worth knowing:

go list -m -u all        # which required modules have newer versions
go get github.com/acme/lib@v1.5.0   # move just this one

Upgrades are per-module and deliberate; nothing moves because someone published. That is MVS paying off — a quiet go.mod diff, a review, a green build.

What belongs in which release is easier with concrete cases: renaming Worker to Processor is major; adding Worker.Name string field is minor (consumers constructing Worker with unkeyed literals would break, and Go treats unkeyed struct literals of external types as the consumer's risk — keyed literals are safe); fixing a panic on empty input is patch. The line between minor and patch for behavior fixes: if a consumer's test asserted the buggy behavior, it is arguably breaking — Go's convention keeps documented behavior stable and lets undocumented details move.

None of this resolves structural problems — when two packages in your own repo need each other, no version helps. That is a design error the compiler reports as an import cycle, covered in Import Cycles and Package Design, while the day-to-day import hygiene lives in Imports, goimports, and go mod tidy.

Quiz

1. Your module requires github.com/acme/logging v1.2.0. A new dependency requires the same library at v1.3.0, and another dependency requires v1.1.0. Which version does your build select?

Question 1 options

2. A library removes a field from an exported struct — a change that breaks compilation for consumers. What is the correct release?

Question 2 options

3. A build fails with `missing go.sum entry for go.mod/file`. What is the correct recovery?

Question 3 options

4. You want to pin a dependency to an untagged commit containing a fix that will ship as v1.6.0 next week. What does go record?

Question 4 options

Flashcards

Question

What exactly does minimal version selection guarantee?

Answer

The build uses the highest version required anywhere in the module graph — every require is a floor and floors combine — and never upgrades beyond what was requested. That makes builds reproducible without a lockfile: the same go.mod always resolves to the same versions.

Question

Why must a v2+ Go library change its import path?

Answer

The module path gains a /v2 suffix so v1 and v2 are distinct modules that can both be required and imported in one binary. Consumers migrate package by package instead of facing an all-at-once break, which is the practical payoff of the rule.

Question

What is the difference between go.mod and go.sum?

Answer

go.mod records which versions the build selects (requirements, the go directive, retractions) — that is the 'which version' half. go.sum records hashes of those versions' go.mod files and source archives — the 'which bytes' half. Versions come from MVS; go.sum only proves the content matches.

Question

Why does go.sum contain hashes for versions that are not being used?

Answer

The module graph must read the go.mod of every candidate version before selection finishes, so their hashes are recorded even when those versions lose MVS. Deleting the 'unused' entries breaks the next graph computation, which is why tidy restores them rather than pruning them.

Question

When is a `retract` directive the right tool?

Answer

After you have already tagged a broken or vulnerable release: retract v1.3.1 in your go.mod warns every consumer that selects it, while anyone who pinned it explicitly can still build. It cannot un-tag the release, so follow it with a fixed v1.3.2.

Revision Notes

Key Takeaways

  • 1. MVS takes the maximum floor requested anywhere in the graph — deterministic, reproducible, and never 'latest'
  • 2. A v2+ release appends /v2 to the module path so old and new versions coexist in one binary
  • 3. go.mod answers which version, go.sum proves which bytes; a missing entry always means the pair was split across commits
  • 4. Pre-releases are opt-in, pseudo-versions pin commits, retractions warn — none of them rewrite history
  • 5. Subdirectory modules need path-prefixed tags or consumers see 'unknown revision'

Interview Tips

  • • "Two dependencies need different versions of the same library — what happens?" — state MVS in one sentence, then walk a concrete max() example and note that downgrades are impossible without removing the higher require
  • • "How would you roll out a breaking API change?" — /v2 path, v2.0.0 tag, both import paths usable at once, consumer migrates incrementally; mention unkeyed struct literals as the gray area
  • • "What does go.sum protect against?" — moved tags and tampered downloads: hashes are checked on download and re-checkable with go mod verify; distinguish content hashing from version selection
  • • "You found a vulnerable release you already published" — retract in go.mod, patch release, tell consumers to upgrade; be ready to explain that pinned versions keep building

Cheat Sheet

go.mod and Semantic Versioning

go.mod anatomy:

Directive Controls
module import path root for every package in the repo
go 1.22 language version + module graph semantics
require selected versions (the build's floors)
// indirect dependency needed only transitively
retract warns consumers away from a bad release

MVS rule: selected version = max of every require across the graph. Never downgrades, never auto-upgrades. go get pkg@vX raises your floor; a dependency's higher floor still wins.

Version digit decision:

Change Version
fix, documented behavior intact patch (v1.5.1)
add exported API minor (v1.6.0)
remove/rename exported API, change signature major + /v2 path (v2.0.0)
test a commit before release go get pkg@<sha> → pseudo-version
bad release already tagged retract + fixed release

Code template (retract + v2):

module example.com/lib/v2

go 1.22

retract v1.3.1 // panics on empty input

Ops: go list -m -u all finds upgrades; submodule tags are prefixed (tools/migrate/v1.4.0); commit go.mod + go.sum together.

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading