Modules & Dependency Management

Difficulty

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.

Related Resources

The module proxy and checksum database work together to make go get/go build both fast and verifiably tamper-resistant.

The default proxy chain

go env GOPROXY
# https://proxy.golang.org,direct

go tries proxy.golang.org first; direct as a fallback means "go fetch it straight from the module's version control host" if the proxy doesn't have it (or if GOPROXY is explicitly set to direct).

Why a proxy at all

Without a proxy:
  go get github.com/some/pkg@v1.2.3
    -> clones directly from GitHub every time, for every developer, every CI run
    -> breaks if the repo is deleted, renamed, made private, or GitHub is down

With the proxy:
  go get github.com/some/pkg@v1.2.3
    -> fetches from proxy.golang.org's immutable cache
    -> works even if the original repo later disappears

Once a module version is fetched through the proxy, it's cached there permanently (module versions are meant to be immutable), which protects against the surprisingly common "left-pad" style problem of a dependency vanishing.

Verifying with GOSUMDB

go env GOSUMDB
# sum.golang.org
go.sum:
github.com/some/pkg v1.2.3 h1:AbCdEf...=

When you first fetch a module version, go checks its hash against sum.golang.org's append-only transparency log and records it in your go.sum. On every subsequent build, go re-verifies the locally cached module against that recorded hash. If the module's content ever silently changed — a compromised registry, a maintainer force-pushing over a tag — the build fails loudly instead of silently using different code.

Disabling for private or restricted environments

GOPRIVATE=github.com/mycompany/* go build ./...
GONOSUMCHECK=1   # older, mostly superseded by GOPRIVATE/GONOSUMDB
GOFLAGS=-mod=mod

GOPRIVATE (covered in a later question) tells go to skip both the proxy and the checksum database for matching module paths, which is essential for internal, non-public modules that a public proxy would never be able to reach anyway.

Related Resources

Vendoring predates Go modules entirely (it was the standard dependency-management approach before modules existed), but it's still a legitimate, supported option today for specific situations.

Generating a vendor directory

go mod vendor
vendor/
  github.com/gin-gonic/gin/
    ...full source of the dependency...
  modules.txt   # a manifest recording exactly which versions are vendored

Building from vendored dependencies

go build -mod=vendor ./...

If a vendor/ directory with a valid modules.txt exists and your Go version is 1.14+, go build actually uses vendored code by default, without needing the explicit flag, unless you override it.

When vendoring earns its cost

  • Air-gapped or heavily restricted build environments that have no outbound network access at all, where fetching from any proxy is impossible during the build itself.
  • Regulatory or audit requirements, where every byte of shipped code must be reviewable directly inside the same repository, not just referenced by a version number pointing elsewhere.
  • Extra protection against dependency unavailability, beyond what the module proxy's caching already provides, for organizations with particularly strict reliability requirements.

The tradeoff

vendor/ can easily add tens of megabytes (or more) to a repository,
and needs `go mod vendor` re-run (and the diff committed) every time
a dependency changes.

For most projects today, the module proxy plus go.sum verification already provides enough reproducibility and availability guarantees, which is why vendoring is the exception rather than the default in modern Go projects — but it remains a fully supported, sometimes necessary option, not a deprecated relic.

Related Resources

Private modules need to bypass two things. The public proxy can't reach a private repo anyway. The public checksum database has no legitimate way to know about code that was never published publicly.

Setting GOPRIVATE

export GOPRIVATE=github.com/mycompany/*,gitlab.internal.corp/*

Any module import path matching one of these glob patterns skips proxy.golang.org and sum.golang.org entirely, going straight to the source host instead.

Configuring Git authentication for CI

git config --global url."https://${GH_TOKEN}@github.com/".insteadOf "https://github.com/"

This rewrites any https://github.com/... URL Go tries to fetch into an authenticated URL using a personal access token or CI-provisioned token, which is the standard way to let go mod download succeed against a private GitHub repository in a CI pipeline.

SSH-based authentication for local development

# ~/.gitconfig
[url "git@github.com:"]
    insteadOf = https://github.com/

This rewrites HTTPS GitHub URLs to SSH, letting Go fetch private modules using your existing SSH key setup, which is common for individual developers who already have SSH access configured for their private repos.

A common gotcha: forgetting GOPRIVATE entirely

This error is easy to misread as a version problem, when it's really an authentication problem:

go: github.com/mycompany/internal-lib@v1.0.0: reading github.com/mycompany/internal-lib/go.mod at revision v1.0.0: unknown revision v1.0.0

Without GOPRIVATE set correctly, go tries the public proxy first, which naturally has no access to a private repository, and the resulting error message is often confusing since it doesn't clearly say "this is a private-module authentication problem."

The practical checklist

Set GOPRIVATE for every private module path pattern your project needs, configure Git credential rewriting appropriately for both local development and CI, and verify with go mod download in a clean environment (or CI) before assuming it's fully working.

Before workspace mode, working across multiple related modules locally meant either publishing intermediate versions constantly, or hand-editing go.mod replace directives and remembering to revert them before committing.

The old workaround: replace directives

// go.mod, before workspace mode
module github.com/you/app

require github.com/you/shared-lib v1.0.0

replace github.com/you/shared-lib => ../shared-lib   // easy to forget to remove before committing

This worked, but it required editing go.mod itself, and it was a constant source of "oops, I accidentally committed a local replace directive" mistakes.

The workspace mode approach

myproject/
  app/
    go.mod        # module github.com/you/app
  shared-lib/
    go.mod        # module github.com/you/shared-lib
  go.work         # ties them together, lives outside both modules' go.mod files
// go.work
go 1.22

use ./app
use ./shared-lib
cd myproject
go build ./app/...   # resolves github.com/you/shared-lib from ../shared-lib automatically

No replace directive needed in either module's go.mod. Editing shared-lib's code and rebuilding app immediately picks up the change, with neither go.mod file needing any temporary edits at all.

Why go.work shouldn't be committed

go work init ./app ./shared-lib   # creates go.work locally

go.work reflects one specific developer's local directory layout and which modules they happen to be actively co-developing at that moment — it's not part of the project's actual dependency graph, which is why the convention is to keep it out of version control (often via .gitignore), similar to a personal IDE config file.

The problem this solves, concretely

Say you need to make a coordinated change across two or more modules you maintain together, like a shared library and an app consuming it. Workspace mode lets you iterate on both simultaneously, with real, immediate feedback. There's no friction from publishing intermediate versions or managing temporary replace directives by hand.

Related Resources