AlgoMaster Logo

Publishing Modules

Last Updated: May 22, 2026

Low Priority
12 min read

Publishing a Go module means making it available so other developers can go get it by its import path. Go's discovery model is unusual: there's no central registry to upload to and no publish command. The module path itself is the canonical location, and the act of publishing is mostly pushing code to that location and tagging a release. This chapter covers the full publish flow, the module proxy that sits between authors and consumers, the environment variables that control fetching, releasing v2 and beyond, retracting bad versions, deprecating a module, and the smaller details that affect how a package shows up on pkg.go.dev.

The Module Path Is the Location

In most ecosystems you upload a package to a registry (npm, PyPI, Maven Central) and consumers look it up by name. Go works differently. The module path declared in go.mod is also the URL where the source lives. When a consumer writes go get github.com/store-co/cart, Go takes that path literally: it's a repository at github.com/store-co/cart, and the tool fetches it directly (or through a proxy that fetched it on someone's behalf).

That equivalence between path and location is what makes publishing simple. There's no separate "register the name" step. If you own the repo at github.com/store-co/cart, you own the module path github.com/store-co/cart, and that's the end of the negotiation.

Here's the go.mod for the cart library:

The module line declares the canonical import path. Anywhere else in the world, code that imports this module will write import "github.com/store-co/cart". Substring matches matter: a repo at github.com/other-co/cart is a completely different module, even though both export packages named cart.

The Publish Flow, Start to Finish

The full sequence for releasing v1.0.0 of a new library is short. Create the repo, push the code, tag the release, push the tag. That's it.

Once the tag is pushed, a consumer anywhere in the world can pull it down:

This is the whole publish loop. No upload, no approval, no waiting for moderation. The tag is the version. Go discovers it by reading the tag list on the remote.

The diagram traces the path of a release. The developer pushes code, tags a version, and pushes the tag. The Go module proxy picks up the new tag on the next request for that version and caches a zip of the source. The checksum database records a cryptographic hash so future consumers can verify integrity. When a consumer runs go get, the proxy serves the cached zip, and the checksum DB confirms the contents match what was originally recorded.

The flow has one thing worth pausing on: the proxy doesn't fetch your code at the moment you push the tag. It fetches lazily, the first time anyone requests that version. If you push v1.0.0 and nobody ever runs go get for it, the proxy never sees it. The version still exists (the tag is on GitHub), but the proxy's cache is built on demand.

The Module Proxy

By default, Go doesn't fetch modules straight from GitHub. It fetches them from proxy.golang.org, a Google-run service that mirrors public Go modules. The proxy is a cache, a CDN, and an availability layer in one. When go get runs, the tool hits the proxy first; the proxy either has the requested version cached or fetches it from the source on the user's behalf.

The proxy exists for a few reasons. It's faster than hitting Git remotes for every build. It keeps builds reproducible if a source repo disappears (the proxy keeps cached copies even if the upstream is deleted, within policy limits). And it shifts load away from the actual source hosts, which makes life easier for projects that host their own Git servers.

The GOPROXY environment variable controls this behavior. Its default value is:

That's a comma-separated list. Go tries each entry in order. The first entry, https://proxy.golang.org, is the public Go proxy. The second entry, direct, is a special keyword meaning "skip the proxy and contact the source directly". If the proxy returns 404 or 410 for a module, Go falls through to direct and tries Git. Any other proxy error (network failure, 500) is not treated as a fall-through; the tool reports the error and stops.

A few common GOPROXY configurations:

ValueMeaning
https://proxy.golang.org,directDefault. Use proxy, fall through to source on 404.
directAlways fetch from source. Skip the proxy entirely.
offDon't fetch anything. Only use modules already in the cache.
https://proxy.corp.example.comUse an internal proxy. No fallback.
https://proxy.corp.example.com,directInternal proxy first, source as fallback.

Setting GOPROXY=off is useful in CI environments where you want builds to fail loudly if a module isn't already vendored or cached. Setting GOPROXY=direct is common for developers behind corporate firewalls that block the public proxy, or for testing changes to the source before any proxy refresh.

A miss at proxy.golang.org adds one round-trip to the source repo. A second go get for the same version is a single hit to the proxy with no Git involvement. The proxy is the reason CI builds for the same dependency graph feel uniformly fast no matter where they run.

The Checksum Database

Alongside the proxy, Go runs a checksum database at sum.golang.org. Every time the proxy first sees a new module version, it records a cryptographic hash of the contents in this database. From then on, anyone fetching the same version verifies their download against that recorded hash. If the contents drift (someone force-pushed a tag, the proxy got compromised, a TLS man-in-the-middle), the verification fails and the build stops with a clear error.

The GOSUMDB environment variable controls the checksum database. The default is:

The first time a module version is added to go.sum (your project's lockfile), the checksum is recorded after a successful verification against GOSUMDB. On every subsequent build, Go re-verifies the downloaded content matches the locked checksum. This is what makes go.sum a real security boundary, not just a convenience.

You can disable the checksum DB with GOSUMDB=off, but doing so on public modules is a security loss. The common case for turning it off is private modules, which the public checksum DB can't see anyway (more on that next).

Private Modules with GOPRIVATE

The proxy and checksum DB work great for public code, but they break for private modules. Your company's internal github.com/mycorp/billing isn't reachable from proxy.golang.org, and sum.golang.org can't verify what it can't see. Trying to fetch a private module with default settings produces an error from one or both services.

The GOPRIVATE environment variable is the fix. It's a comma-separated list of glob patterns matching module paths that should bypass both the proxy and the checksum DB:

Any module whose path matches one of those patterns is fetched directly from source and not verified against the checksum DB. Authentication for the underlying Git remote (SSH keys, HTTPS tokens) is handled by Git, not by Go.

If you need finer control, two other variables exist:

  • GONOPROXY matches paths that should bypass only the proxy (still verified by the checksum DB if reachable). Setting GOPRIVATE implicitly sets GONOPROXY to the same value.
  • GONOSUMCHECK (set via GONOSUMDB in practice) matches paths that should bypass only the checksum DB. Setting GOPRIVATE implicitly sets GONOSUMDB too.

For nearly all cases, set GOPRIVATE and forget the other two. It's the umbrella that covers both bypasses.

With that set, go get github.com/store-co/internal-cart skips the public proxy, fetches directly from git@github.com:store-co/internal-cart.git using your local credentials, and skips the checksum DB lookup. Public modules are unaffected.

A rarer setting is GOINSECURE, which lists module paths that can be fetched over plain HTTP instead of HTTPS. It's almost never the right answer; if your internal Git server speaks HTTPS, configure that properly instead. The setting exists mostly for legacy setups inside isolated networks.

Releasing v2 and Beyond

Go modules treat v0 and v1 as one rolling line where minor and patch versions ship updates without ceremony. v2 is different. Semantic versioning says v2 means "I broke something on purpose", and Go's module system makes that breaking change visible at the import path level. The module path for v2 must end in /v2. The path for v3 ends in /v3. And so on.

The reason is import path stability. A package that imports github.com/store-co/cart should keep getting the v1 API forever, even after v2 ships, because v2 is a different module path. Code that wants v2 explicitly opts in by importing github.com/store-co/cart/v2. Both versions can coexist in the same build, in different parts of the dependency graph, without conflict.

The recommended approach for releasing v2 is to bump the module path on the main branch:

Two things changed: the module path picked up /v2, and any in-tree imports of subpackages of this module need updating (import "github.com/store-co/cart/v2/discount" instead of import "github.com/store-co/cart/discount"). Once the path is right, tag v2.0.0 and push:

From that moment, go get github.com/store-co/cart/v2 resolves to whatever is at the v2 tag, and go get github.com/store-co/cart still resolves to v1 (the highest v1 tag). Both APIs are reachable.

Aspectv1 releasev2+ release
Module pathgithub.com/store-co/cartgithub.com/store-co/cart/v2
Import path in user codeimport "github.com/store-co/cart"import "github.com/store-co/cart/v2"
Tagv1.0.0, v1.1.0,...v2.0.0, v2.1.0,...
RepositoryOne repoSame repo, updated go.mod
Coexists with prior major?N/AYes, different import path

There's a second, less common approach: keep v1 on the main branch and put v2 in a v2/ subdirectory of the repo. Each major version lives in its own folder with its own go.mod. This is useful when you want to actively maintain both major versions in parallel. Most projects don't, so the main-branch upgrade is the path is most often. The Go documentation explicitly calls both approaches valid.

Retracting a Bad Release

Occasionally a release ships broken. A logic bug in v1.2.0 that crashes consumers, a published version that accidentally included a security flaw, a tag pushed from the wrong branch. The old answer was to delete the tag, but deletion is messy: anyone who already pulled the version still has it in their go.sum, and the proxy may have cached it anyway.

The retract directive is the clean fix. Added to go.mod, it tells the Go tool "this version exists but should not be selected". Consumers running go get @latest skip retracted versions. Consumers who explicitly request a retracted version (go get @v1.2.0) still get it but see a warning.

The // comment after each retract line is the human-readable reason, which Go tools surface to users. The bracketed range form retracts a contiguous span; useful when several adjacent versions share a problem.

To publish a retraction:

  1. Add the retract directive to go.mod on the main branch.
  2. Commit and push.
  3. Tag a new version (typically a patch above the retracted ones) and push the tag.

Now go get github.com/store-co/cart@latest resolves to v1.2.1 and skips v1.2.0. The retracted tag still exists in the repo, but the version selection algorithm passes over it.

A subtle point: the retract directive lives in the go.mod of a newer version than the one being retracted. Retracting v1.2.0 requires shipping v1.2.1 (or any later tag). The retraction itself is part of a release; it's not a separate operation.

Deprecating a Module

retract removes specific versions. To signal "the whole module is going away, use something else", use a deprecation comment on the module line itself:

The exact form is a // Deprecated: comment immediately above the module directive. Go tools recognize this prefix and surface the message to consumers running go list -m -u or working in editors with module awareness. The deprecation doesn't break anything; the module still resolves, the API still works. It's a signal, not a block.

This is the standard tool for "v1 is end-of-life; switch to v2" or "this library has been replaced by a successor with the same purpose". It's not the standard tool for "this version has a bug"; that's what retract is for.

pkg.go.dev and Documentation

pkg.go.dev is the public documentation portal for Go modules. Every module fetched through proxy.golang.org is automatically indexed there. There's no manual submission, no opt-in form. The first time a consumer anywhere runs go get github.com/store-co/cart, the proxy fetches the module, the checksum DB records it, and within minutes the docs appear at pkg.go.dev/github.com/store-co/cart.

The quality of those generated docs depends on what you ship:

  • Doc comments on exported names turn into rendered prose for every function, type, and constant. Write them.
  • A README.md at the module root becomes the landing page. Make it useful: install command, a small example, a link to a longer guide.
  • A LICENSE file at the module root is required for pkg.go.dev to display the module without a warning. Common choices are MIT, Apache-2.0, and BSD-3-Clause.
  • **Examples in *_test.go files** following Go's testable example convention render as runnable examples on the docs page. They're one of the highest-use forms of documentation.

The pkg.go.dev quality grading is informal but visible. A module with a license, a README, and doc comments looks ready for serious use. A module without these reads as half-published. If you want consumers to take your library seriously, fill in all three.

The first request for a new module version pays the proxy's cold-fetch cost (one Git clone, one tarball, one checksum write). Every subsequent request is a CDN hit. Practically, this means the first time anyone runs go get for a brand-new tag, they wait a second or two longer than the second person ever will.

Hosts Other Than GitHub

Go's path-based discovery is host-agnostic. The same flow works for GitLab, Bitbucket, self-hosted Git, and anywhere else, as long as Go can recognize the URL pattern. For the major hosts, the recognition is built in:

HostPath pattern
GitHubgithub.com/{user}/{repo}
GitLabgitlab.com/{user}/{repo}
Bitbucketbitbucket.org/{user}/{repo}
Codebergcodeberg.org/{user}/{repo}

Tag your release on whichever host you use, push the tag, and go get works exactly the same as it does for GitHub. No special configuration.

For self-hosted Git or domains Go doesn't recognize, you can publish a vanity import path using HTML <meta> tags. The idea is to host a small static page at example.com/foo that includes a <meta name="go-import"...> tag telling Go where the actual repository lives. Consumers write import "example.com/foo", Go fetches the HTML, reads the meta tag, and pulls the source from wherever it really lives. This is how projects like golang.org/x/crypto work, decoupling the canonical import name from the storage host.

Vanity imports add an operational dependency (the website hosting the meta tags has to stay up), so most projects skip them and use a host like GitHub directly. They're about but rarely worth setting up.

Pre-release Versions

Sometimes you want to ship a version that's almost ready but shouldn't be picked up by everyone running go get @latest. Semantic versioning's solution is the pre-release suffix, written after a hyphen: v1.3.0-rc.1, v2.0.0-beta, v1.0.0-alpha.2.

Pre-release versions are real tags that Go can resolve, but they're not considered "latest" by the version selection algorithm. A user running go get github.com/store-co/cart@latest will skip v2.0.0-beta and pick v1.9.5 instead. To opt in to a pre-release, the user has to request it explicitly:

This makes pre-releases safe to publish. You can tag and push them freely, and consumers won't be surprised by experimental code. The convention is to use -alpha.N, -beta.N, or -rc.N (release candidate) for stages, but Go doesn't enforce any particular naming; anything after the hyphen is a pre-release.

Putting a Real Release Together

Here's the end-to-end flow for shipping a clean v1.0.0 of github.com/store-co/cart:

A consumer anywhere in the world can now run:

And see output similar to:

Within a few minutes, pkg.go.dev/github.com/store-co/cart will display the rendered docs, with the README as the landing page and every exported function appearing under its package section. There's nothing else to do. The release is live.

The Go module system trades convenience features (a centralized registry, search rankings, owner verification) for simplicity (the path is the location, the tag is the version). Once you've published one module, the next one follows the same steps.