skip to content

Semantic Import Versioning (v2+)

Go's unusual rule that a breaking major version changes the import path itself — example.com/lib/v2 — so two majors can coexist in a single build. Interviewers use it to check you understand why Go has no "bump the major and pray" upgrade.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

5

Why must a Go module's path and its import paths end in /v2 once it releases v2.0.0?

level: juniorimportance: must knowfreq 70%

answer

  1. one version per import path
  2. a breaking change needs a new name
  3. the major version lives in the path
  4. v0 and v1 are exempt
  5. both majors can coexist in one binary

basics

~20 s

Go treats each major version as a separate module. From v2 on, the module line in go.mod and every import of its packages must end in /v2, so v1 and v2 can live in one build.

solid answer

~40 s

Go identifies a package by its import path, and the build list holds exactly one version per module path. Semantic import versioning resolves that by putting the major version into the path itself: from v2 onward the `module` line in `go.mod` must end in `/v2`, and callers write `import "example.com/lib/v2/thing"`. v0 and v1 are exempt and keep the bare path. The practical consequence is that v2 is not an in-place upgrade — every consumer edits import lines, and until they do they keep compiling against v1, which is deliberate. It also means one binary may legitimately link both `example.com/lib` and `example.com/lib/v2`, which is what makes an incremental migration possible. The standard library follows the same convention: `math/rand/v2` sits alongside `math/rand`.

code

mod · 10 lines
mod
// go.mod of the library itself
module example.com/lib/v2

go 1.27

// go.mod of a consumer that is migrating gradually
require (
	example.com/lib v1.9.0
	example.com/lib/v2 v2.3.0
)

go deeper

for a junior

Be ready to state the rule cleanly: from v2 onward the module path and the import paths gain a /v2 element, while v0 and v1 use the bare path. Knowing that v2 is not an automatic upgrade is the part interviewers listen for.

for a middle

Explain the mechanism behind the rule — one version per module path in the build list, so a breaking change must become a new path. Be able to say what breaks when a repo tags v2.0.0 without editing its module line.

for a senior

Show you have migrated a real codebase across a major: both paths required at once, package-by-package rewrite, and the seams where a v1 value meets a v2 function. Mention that go get -u never carries a team across on its own.

for a principal

Own the consequence for your organisation: a /v2 reaches nobody until every consumer edits imports, so the cost lands on the teams you depend on you. Be ready to say when you would keep a change inside v1 instead.

## The problem it solves In Go, a package is named by its **import path**, and the resolved build list contains at most one version of any given module path. If `example.com/lib` could mean either the v1 API or an incompatible v2 API, then two dependencies of your program that each need a different one could never be satisfied at the same time, and worse, upgrading a transitive dependency could silently swap the meaning of an import you never touched. Go's answer is the **import compatibility rule**: *if an old package and a new package have the same import path, the new package must be backwards compatible with the old one*. A breaking change therefore has to be a new import path. ## The rule From major version 2 onward, a module's path must end in a **major version suffix** matching the version it releases: - `example.com/lib` releases v0.x.y and v1.x.y - `example.com/lib/v2` releases v2.x.y - `example.com/lib/v3` releases v3.x.y The suffix appears in three places that must agree: the `module` line of the module's own `go.mod`, the `require` lines of everyone who depends on it, and every `import` statement that reaches into its packages. A package that lived at `example.com/lib/store` in v1 is imported as `example.com/lib/v2/store` in v2. ## The v0 and v1 exemption v0 and v1 use the bare path with no suffix. The reasoning differs for each: - **v1** is exempt for continuity — module paths predate modules, and the enormous body of pre-existing code that imports `example.com/lib` is, by the rule above, v1-compatible code. Making v1 write `/v1` would have broken every import in the ecosystem for no gain. (`example.com/lib/v1` is not an alias for the bare path; it is simply a different, usually non-existent, path.) - **v0** is exempt because SemVer gives v0 no compatibility promise at all. A v0 module may break on any minor release, so no path change is required — and that is exactly why staying at v0 is a signal to consumers that the API is still moving. ## What it feels like in practice Because `example.com/lib` and `example.com/lib/v2` are *different module paths*, the go command sees two unrelated modules. Consequences worth internalising: 1. **`go get -u` never crosses a major boundary.** Updating your dependencies moves you to the newest v1.x, forever. Adopting v2 is an explicit act: `go get example.com/lib/v2` plus an import rewrite. 2. **Both majors can be in one build.** `go.mod` may require `example.com/lib v1.9.0` and `example.com/lib/v2 v2.3.0` simultaneously, and the go command is perfectly happy — they are different modules. This is what lets a large program migrate package by package instead of in one commit. 3. **The compiler treats their types as unrelated.** A `lib.Config` and a `lib/v2.Config` are two distinct named types, because a type's identity includes the import path of the package that defines it. If a value has to cross a boundary between code on v1 and code on v2, you need an explicit conversion or an adapter. 4. **Tooling reads the suffix, not the tag alone.** If a repository tags v2.0.0 while its `go.mod` still says `module example.com/lib`, the go command rejects that version at the bare path and tells you the module path must end in `/v2`. The one exception is a repository with **no `go.mod` at all** at that tag, which is accepted at the bare path as a `+incompatible` version. ## Seen in the standard library The convention is not just for third parties. `math/rand/v2` was added in Go 1.22 as an incompatible redesign of `math/rand`, and it sits beside the original rather than replacing it, with both importable from the same program. That is semantic import versioning applied inside the standard library. ## The cost, stated honestly The rule buys reproducibility and the absence of diamond-version conflicts, and it charges for it in migration friction: a v2 release reaches nobody until each consumer edits their imports, and the library author now maintains two trees. That trade is the whole reason a Go library author thinks hard before cutting a v2 at all, and it is why so many Go libraries stay on v1 for years and add capability through new functions and option types rather than through a new major.

  • Why are v0 and v1 exempt from the suffix?
    v1 is exempt for continuity: module paths predate modules, and all the code already importing `example.com/lib` is by definition v1-compatible, so requiring `/v1` would have broken the ecosystem for nothing. v0 is exempt because SemVer promises nothing at v0 — a v0 module may break on any release, so there is no compatibility claim for a path change to protect.
  • Can one program import both example.com/lib and example.com/lib/v2?
    Yes, and it is a supported migration path. They are different module paths, so `go.mod` may require both and the linker includes both. The catch is that their types are unrelated and each has its own package-level state, so it works cleanly only while values do not cross between the two.
  • Does go get -u move a consumer from v1 to v2?
    No. `go get -u` upgrades within a module path, and `example.com/lib/v2` is a different path, so a consumer on v1 stays on the newest v1.x indefinitely. Adopting v2 means requiring the `/v2` path explicitly and rewriting the import lines — the go command will never do it as part of a routine update.

saying these in an interview costs you the question

  • Says the /v2 suffix is only a naming convention
  • Claims go get -u upgrades a consumer to v2
  • Thinks v1 modules must import as example.com/lib/v1
  • Believes only one major of a library can be in a build
  • Assumes tagging v2.0.0 is enough without editing go.mod
open as a page

In go.mod, what does a require line like example.com/lib v3.2.0+incompatible mean?

level: middleimportance: should knowfreq 42%

basics

~20 s

The +incompatible marker means the dependency is tagged at major version 2 or higher but has no go.mod file at that tag. Never being modules-aware, it is allowed to keep the bare path with no /v2 style suffix.

open as a page

A Go build links both example.com/lib and example.com/lib/v2 — why do their types refuse to interoperate?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Because a Go type's identity includes the import path of the package that defines it. Two majors are two module paths, so lib.Client and lib/v2.Client are unrelated types, with separate package-level state and separate sentinel errors.

open as a page

As a Go library owner, how do you decide between cutting a /v2 module path and keeping a change inside v1?

level: principalimportance: should knowfreq 30%

basics

~20 s

Weigh what a /v2 costs everyone else. Because consumers must rewrite import paths by hand and no upgrade command crosses a major, a v2 reaches nobody automatically and you maintain two trees. Cut one only when the break cannot be made additive.

open as a page

How do you lay out a Go repository so it can publish both v1 and v2 of the same module?

level: middleimportance: nice to knowfreq 35%

basics

~20 s

Two layouts work. Put v2 in a v2/ subdirectory with its own go.mod whose module line ends in /v2, or keep it on a branch whose root go.mod ends in /v2. Either way the module's own imports must gain /v2.

open as a page