Last Updated: May 22, 2026
Every Go module declares its identity with a path and its release history with version tags. Together, those drive a small but strict set of rules that decide which code your build actually picks up. This chapter covers Go's flavor of Semantic Versioning, the import-path rule for v2 and beyond, pseudo-versions, pre-release tags, version queries, Minimum Version Selection, and the replace and exclude directives in go.mod.
Go versions look like v1.2.3. The leading v is mandatory, the three numbers are MAJOR.MINOR.PATCH, and the meaning matches Semantic Versioning everywhere else:
A module's tags live on its source repository. When the maintainers of github.com/store-co/cart push a tag named v1.4.2, Go sees that as a release. Other tag shapes (1.4.2 without the v, release-1.4.2, v1.4) aren't recognized and won't be picked up by go get.
Here's a typical go.mod for a store backend that imports the cart package:
The require lines list direct dependencies and the version Go has selected for each. We'll get to how that selection works in a moment.
A module on a v0.x.y release is explicitly unstable. The author is allowed to break anything in any minor or patch release. If you depend on github.com/store-co/catalog v0.7.1, an upgrade to v0.8.0 can rename functions, remove types, or change behavior entirely.
This isn't a bug in the convention, it's the convention. v0 says "I'm still figuring this out". Treat any v0 dependency as pinned, and read the release notes before upgrading.
Once a module tags v1.0.0, the rules tighten. The author promises that any further v1.x.y release is backward compatible. Adding new exported functions is fine. Removing them, changing their signatures, or changing their documented behavior isn't, unless the major version bumps.
If the cart team needs to break the Checkout function's signature, they can't ship that as v1.5.0. They have to publish v2.0.0. And in Go, publishing v2.0.0 has a consequence that doesn't exist in most other ecosystems.
This is the rule that surprises people coming from other languages. For any major version of 2 or higher, the module's import path must include the major version as a suffix.
| Major version | Module path | Import in your code |
|---|---|---|
| v0 or v1 | github.com/store-co/cart | import "github.com/store-co/cart" |
| v2 | github.com/store-co/cart/v2 | import "github.com/store-co/cart/v2" |
| v3 | github.com/store-co/cart/v3 | import "github.com/store-co/cart/v3" |
The version suffix is part of the path, not metadata bolted on. Go calls this semantic import versioning, and the payoff is that two major versions of the same module can coexist in a single build. Your store binary can depend on github.com/store-co/cart (a v1.x release) and github.com/store-co/cart/v2 (a v2.x release) at the same time, because as far as the toolchain is concerned, those are two different modules with two different import paths.
The diagram shows what wouldn't be possible in a system without semantic import versioning. Without distinct paths, the build would have to choose one major version and force every part of the program to use it. With distinct paths, the old catalog code keeps importing cart from the v1 path, and the new checkout code imports from the v2 path, and both compile cleanly into the same binary. This is a deliberate trade-off: a small amount of friction at publish time (renaming the module path) buys a lot of flexibility at consume time.
A go.mod that uses both majors looks like this:
The two paths are unrelated as far as the module system is concerned. Each gets resolved independently, each contributes its own checksums to go.sum, and your code imports from whichever path it wants.
The short version of the publishing flow: when the cart team is ready to release v2, they update the module line in their go.mod to github.com/store-co/cart/v2, commit, and tag v2.0.0. From that point on, the same repository serves both major versions. A consumer importing the old path gets the last v1.x release; a consumer importing /v2 gets the new line.
Not every commit you depend on has a tag. Maybe you're tracking a feature branch, or the upstream maintainer hasn't cut a release yet, or you need a specific bug fix that hasn't shipped. Go handles these with pseudo-versions: synthetic version strings the toolchain builds out of a commit timestamp and hash.
The format is:
Three parts, joined with hyphens:
| Segment | Meaning |
|---|---|
v0.0.0 | Base version. May be vX.Y.Z-0 for pre-tag commits on a higher line. |
20250114153012 | Commit timestamp in UTC, formatted YYYYMMDDHHMMSS. |
abcdef123456 | First 12 characters of the commit hash. |
You don't write these by hand. Go generates them when you run go get against a branch name or commit:
The exact base depends on the tags around the commit:
v1.4.2 and the commit comes after it, the base looks like v1.4.3-0 (the next patch version, marked pre-release with -0).v0.0.0.Once Go generates a pseudo-version, it goes into go.mod like any other version:
The build is now reproducible. Anyone running go build against this go.mod resolves the same commit, fetches the same code, and gets the same checksum recorded in go.sum.
Pseudo-versions pin you to one specific commit, which is fine for short stretches but painful long-term. Branch heads move; the next go get @main resolves a different pseudo-version. Pin to a real tag whenever the upstream cuts one.
Sometimes a module needs to publish a release candidate or beta before going stable. SemVer allows this with a pre-release suffix:
The suffix is anything after a hyphen. Common conventions are alpha, beta, rc (release candidate), with an optional dotted counter.
The important rule: a pre-release version sorts lower than the same version without the suffix. So v1.5.0-beta.1 < v1.5.0, and v2.0.0-rc.1 < v2.0.0. This means go get @latest will skip pre-releases by default, even if a pre-release tag is the newest tag on the repository. You have to opt in by asking for the version directly:
The same ordering applies inside the toolchain when it compares versions during selection. A module requiring v1.5.0 will not be satisfied by v1.5.0-beta.1 because the pre-release is older.
go getThe go get command takes an optional @query to say which version you want. The queries you'll actually use day to day:
| Query | Meaning |
|---|---|
@latest | Highest non-pre-release version. The default if you omit @. |
@v1.2.3 | Exact version. |
@v1.2 | Highest patch within v1.2. Resolves to something like v1.2.7. |
@v1 | Highest minor and patch within v1. |
@upgrade | Latest version compatible with the current go.mod. |
@patch | Latest patch of the currently selected minor. |
@master, @main | Latest commit on that branch (becomes a pseudo-version). |
@<commit-sha> | A specific commit (also becomes a pseudo-version). |
Some examples from a real upgrade session:
The first form took us to the newest stable release. The second pinned us to the v1.4 line and picked the latest patch on it. The third asked for "stay on the current minor, just take the newest patch", which is the safest of the three.
To list every version the upstream has published, use go list:
That output is straight from the module proxy. Pre-release versions don't appear unless you also pass -retracted or query the proxy directly.
Here's where Go's approach diverges from most other package managers. When your build has multiple requirements for the same module, Go uses an algorithm called Minimum Version Selection (MVS). The selected version is the highest of the minimum versions that anything in the build requires.
Read that twice. "Highest of the minimums", not "latest available". MVS doesn't go shopping for the newest version on the registry. It looks at what every module in the dependency graph asks for, takes the maximum of those asks, and stops there.
A concrete example. Your store app imports a money library and a tax library. Both of them depend on a decimal library, but they ask for different versions:
The store app, money, and tax each have an opinion: v1.2.0, v1.3.0, and v1.4.1. MVS picks v1.4.1, the highest of those three. Even though v1.5.0 and v1.6.0 exist on the proxy, Go doesn't choose them. Nothing in the build asked for them.
This contrasts with npm-style resolution. In npm, a dependency declaration like "decimal": "^1.4.1" permits any compatible newer version, and the resolver typically picks the latest one available at install time. That makes installs depend on when you ran them: install today, get v1.4.1; install tomorrow after a v1.4.2 release, get v1.4.2. Lockfiles patch over this, but the underlying algorithm is "highest compatible".
Go inverts the assumption. The version in go.mod isn't an upper bound or a range, it's the exact version that build will use. The only way to get v1.5.0 is for something in the graph to explicitly ask for v1.5.0. This is more verbose but it's also more reproducible. The same go.mod and go.sum always produce the same build, on any machine, today, tomorrow, three years from now. No "well, on Tuesday I got a different transitive minor".
When the decimal library publishes v1.4.2 (a patch fix), it doesn't sneak into your build. You have to run go get github.com/some/decimal@v1.4.2 to update the requirement, which writes the new version into go.mod, which then becomes the new floor for MVS. The decision is yours, recorded explicitly, reviewed in a pull request.
MVS keeps builds reproducible, but it also means security patches don't arrive automatically. Run go list -m -u all periodically to see which dependencies have newer versions available, and go get the ones you want to upgrade.
The require block in go.mod lists everything the build needs, but not all of those modules are imported by your code directly. Some are pulled in by your dependencies. Go marks the second kind with an // indirect comment:
In this file, the store imports cart and catalog directly. Neither of those uses decimal or golang.org/x/text as part of their public API, but their internal code depends on those modules, so MVS needs to know which versions to pin. The // indirect comment is the toolchain's way of saying "your code doesn't mention this module, but the build still depends on it".
Indirect requirements show up in go.mod for two main reasons. First, if a direct dependency's go.mod is missing required minimums (an older module that pre-dates module graph pruning), Go adds them to your file to keep MVS deterministic. Second, if a replace directive elsewhere in your graph forces a particular version that wouldn't otherwise be selected, the requirement gets recorded.
Running go mod tidy regenerates the require block from scratch by walking the full import graph. After tidy, every module Go decided is needed appears in go.mod, with // indirect on the ones your code doesn't import directly. If you delete an indirect requirement by hand, tidy will add it back.
replace DirectiveSometimes you need to override which code answers a module path. The replace directive in go.mod lets you do that, and it has a few legitimate use cases.
You're working on the store binary and the cart module in the same afternoon. The cart changes aren't published yet, but you need the store to pick them up. Point cart at the local checkout:
The right-hand side is a filesystem path, relative to the directory containing go.mod. When Go resolves github.com/store-co/cart during this build, it reads source files from ../cart instead of fetching from the proxy. The version on the require line is mostly decorative at this point; what matters is the local path.
This is how most monorepo and side-by-side workflows operated before Go workspaces existed. It still works, and it's still common.
You've forked a third-party module to fix a bug, and you want your build to use your fork while the upstream PR sits in review:
The replacement target can be another module path (with its own version). Go will fetch that module from the new path and use its code wherever the original was requested. The import statements in your source code don't change. You still write import "github.com/some/decimal". Only the resolution is different.
A specific version of an upstream module has a bug, and you've prepared a tagged patch on a fork. Pin to it:
The version on the left of => makes the replace conditional: it only fires for that exact version. If MVS later selects v1.4.2, the replace doesn't apply, and the original module is used.
replace directives are only honored in the main module. If you publish a library that has a replace line in its go.mod, anyone who imports your library ignores that line entirely. The replace works for your library's tests and local builds, but consumers of the published version don't see it.
This is intentional. The module graph would become a nightmare if every library could rewrite its dependencies for everyone downstream. The trade-off is that replace is a tool for the application at the top of the build, not for libraries trying to nudge their dependents.
The diagram makes the rule visual. Only the main module's replace lines reach the resolver. Lines in any other module's go.mod are read for the module graph (so MVS knows what versions the library claims to want) but the replacements themselves are dropped.
exclude Directiveexclude tells Go to skip a specific version when MVS is choosing. It rarely comes up in practice, but it exists.
After this line, MVS treats v1.4.7 as if it weren't published. If something in the graph requires v1.4.7, the resolver picks the next available version higher than v1.4.7. The most common reason to use exclude is a broken release that the upstream hasn't retracted yet.
Like replace, exclude is only honored in the main module's go.mod. Libraries can't force consumers to skip versions on their behalf.
A short tour of the commands you'll use when versioning is in play:
go list -m -versions prints every published version of a module. go list -m -u all lists every module in the build with available upgrades shown in brackets. go get @version performs the upgrade and rewrites go.mod. go mod tidy walks the imports and synchronizes go.mod and go.sum against what your code actually uses.
One occasional flag: go mod tidy -compat=1.21 tells tidy to keep go.mod in a state that works with toolchains as old as Go 1.21. This matters when your module needs to support a range of Go versions and you don't want tidy to drop indirect requirements that an older toolchain would still need. The default is to track the Go version declared in go.mod.
Here's a realistic go.mod for the store backend, with everything we've discussed in play:
Walking through it line by line:
v1 and v2 of cart because different parts of the codebase haven't been migrated yet. Each lives at its own import path.catalog is unstable; any minor bump could break the build, which is why the version is pinned exactly.decimal is used by store code directly, no // indirect.golang.org/x/text arrived because one of the other modules needs it. The store's own code doesn't import it.v1.4.2 until the developer is ready to publish.decimal v1.4.0 is a known-bad release; MVS skips it.When the toolchain builds this module, it walks every import, asks every dependency what versions it requires, applies the replace and exclude rules from this go.mod (and only this one), and runs MVS over the rest. The result is one selected version per module path, recorded as the require lines you see above and locked with hashes in go.sum.