How does semantic import versioning work for major version upgrades?
Quick Answer
Go modules follow semantic import versioning: for major version 2 and above, the module's import path itself must include the major version, like github.com/user/pkg/v2, and the module directive in that version's go.mod must match. This means pkg (implicitly v0/v1) and pkg/v2 are treated as completely separate packages by the Go toolchain, which lets a single program import both v1 and v2 of the same library simultaneously, as different, unrelated import paths — useful during a gradual migration. This rule only applies from v2 onward; v0 and v1 share the same import path with no /v1 suffix, since v0/v1 changes are expected to remain (relatively) backward compatible within the module's lifetime.
Detailed Answer
Go's approach to major version upgrades is unusual compared to most package ecosystems, and it directly avoids a whole class of dependency-resolution conflicts.
The import path rule
// v0 or v1: no version suffix in the import path
import "github.com/user/pkg"
// v2 and above: version suffix required
import "github.com/user/pkg/v2"
// go.mod for the v2 release
module github.com/user/pkg/v2
go 1.22
The module path declared in go.mod must match the imported path exactly, including the /v2 suffix, for any major version 2 or higher.
Why this rule exists
Without it, say your program depends on two other libraries: one requiring pkg v1, another requiring pkg v2, with breaking API changes between them. The module system would have no way to satisfy both at once, since only one version of anything sharing an import path can be selected per build. Baking the major version into the import path itself fixes this. pkg and pkg/v2 become entirely separate paths, so both can coexist in the same dependency graph without conflict.
Practical effect on migration
import (
oldpkg "github.com/user/pkg"
newpkg "github.com/user/pkg/v2"
)
A codebase can import both versions side by side, under different aliases, letting a team migrate call sites from v1 to v2 incrementally rather than needing a single atomic cutover across the entire codebase.
Why v0/v1 don't need this
v0 (explicitly unstable) and v1 (the first stable API) share the same bare import path. The module system assumes a v1.x.y upgrade stays backward compatible per semver rules, so there's no need to distinguish versions at the import-path level. That distinction only happens at the dependency-resolution level, via go.mod's version constraints.