Hermes Wiki
Developer/Languages/Go/ModulesAndPackaging/Fundamentals/go-modules-versioning-and-multi-module-workspaces

Go Modules: Versioning and Multi-Module Workspaces

Concept

A Go module is a tree of packages rooted at a go.mod file, which declares the module's own import path, the Go language version it targets, and a require list of dependency modules and versions. go.sum sits alongside it recording a cryptographic hash for every module version that enters the build — not just direct dependencies, but the whole resolved graph — so a go build on a different machine is verified against tampering or an accidentally-substituted version, not merely pinned to a version number the way a bare manifest would be.

Dependency resolution uses Minimum Version Selection (MVS): given the whole module graph (your requirements plus your dependencies' own requirements), the build picks the minimum version of each module that satisfies every requirement in that graph — never automatically the newest available. This is a deliberate departure from resolvers like npm's or Cargo's, which pick the newest version compatible with a semver range: MVS makes builds deterministic from the go.mod files alone, with no separate lockfile-vs-manifest reconciliation step, at the cost of never picking up a newer compatible version without an explicit go get -u.

Modules version themselves via git tags following semver (v1.2.3), and Go enforces semantic import versioning for major version 2 and above: a v2 (or later) release must change its module path to include the major version (example.com/mod/v2), because Go treats v1 and v2 of the same module as different modules that can coexist in one build graph — this is how Go avoids the "diamond dependency" problem where two transitive dependencies need incompatible major versions of the same library.

go.work (Go 1.18+) solves a narrower, very common problem: developing several interdependent modules locally — a library and the service that consumes it, in a monorepo or across repos — without pushing every library change to get a version bump the consumer can go get. A go.work file lists local module directories and makes the toolchain resolve imports between them from disk instead of the module cache, for every command run from that workspace. It is explicitly a local development convenience: by convention go.work is never committed, so it never changes what a CI build or another developer's checkout resolves.

Tradeoffs

Approach Benefit Cost
Minimum Version Selection (Go's resolver) Deterministic from go.mod alone; no separate lockfile can drift out of sync with the manifest Never opportunistically picks up a newer compatible dependency version — requires an explicit go get -u
Newest-compatible-version resolvers (npm, Cargo) Automatically benefits from compatible patches/minor releases without manual bumps A lockfile becomes load-bearing for reproducibility, and "what version did I actually get" depends on when install ran
go.work for local multi-module dev Edit a library and its consumer together, see the effect immediately, no publish/tag/bump cycle Workspace-local by design — never committed, so it fixes nothing for CI or other developers; easy to forget it's active and be confused why an old published version isn't reflecting a local edit
replace directive in go.mod (filesystem path) Works without a workspace file, persists across editor restarts If accidentally committed, breaks the build for every teammate/CI whose checkout doesn't have that exact local path — the module still resolves fine for the author, which is exactly what makes the mistake easy to miss
Vendoring (go mod vendor) Fully offline, reproducible builds with no dependency on the module proxy being reachable Duplicates dependency source into the repo; adds a step (go mod vendor) that must stay in sync with go.mod/go.sum or builds silently use stale code

When to use / when not to

  • Reach for go.work specifically while actively co-developing two or more modules that import each other — a shared internal library plus its consumers, or splitting a monolith's packages into separate modules incrementally.
  • Use the replace directive (committed) only for a long-lived, intentional situation — pinning to a maintained fork because upstream is stalled — and document why, since it silently overrides whatever version was requested.
  • Vendor (go mod vendor) when the build environment genuinely cannot reach the module proxy (airgapped CI, regulated environments) — not as a default, since it adds a repo-size and sync-maintenance cost most projects don't need.
  • Run go mod tidy before every commit that touches imports — it both adds missing requirements and removes ones no longer used, keeping go.mod/go.sum an accurate reflection of what the code actually imports.

Common pitfall

Leaving a filesystem-path replace directive in go.mod after finishing local multi-module work, instead of using (and deleting) a go.work file for it. The build keeps working for the author, whose checkout happens to have the referenced path, which is exactly why the mistake ships: CI and every other developer's checkout fail with a "directory not found" resolution error the author cannot reproduce locally. go.work exists specifically to remove this failure mode, since it's a separate, git-ignored-by-convention file that never becomes part of what go.mod commits to.

Engineering Lens

The module system's design choices — MVS over newest-compatible selection, hash-verified go.sum, major-version-in-import-path — all optimize for the same thing: a build that resolves identically everywhere it runs, with no implicit "whatever the resolver picked today" state living outside version control. go.work is the one deliberate escape hatch from that philosophy, and its power comes entirely from staying out of committed history — a review of any Go dependency change should distinguish "this changes what every build resolves to" (go.mod/go.sum edits) from "this only changes what my machine resolves to right now" (go.work), since conflating the two is how a local-only convenience turns into a broken build for someone else.

Sources

Hermes Wiki