Go modules are one of the more carefully designed dependency management systems I’ve worked with. The core ideas — versioned module paths, minimum version selection, reproducible builds via go.sum — are sound. After running services on modules since 1.11, the rough edges are predictable enough to document.

The Core Model

A Go module is a collection of packages versioned together, declared in go.mod:

module github.com/myorg/pricing-service

go 1.21

require (
    github.com/jackc/pgx/v5 v5.5.4
    github.com/prometheus/client_golang v1.18.0
    go.opentelemetry.io/otel v1.24.0
)

The module path (github.com/myorg/pricing-service) is a globally unique identifier. It doesn’t need to be a real URL, but it must be unique to avoid collision with other modules.

Minimum Version Selection

The algorithm that resolves dependency versions is called Minimum Version Selection (MVS). Unlike most package managers, MVS selects the minimum version that satisfies all constraints, not the latest compatible version.

If your module requires libfoo v1.2 and one of your dependencies requires libfoo v1.3, MVS selects v1.3 — the minimum that satisfies both. It does not select v1.5 (latest) even if it exists.

This has a critical property: builds are reproducible by default. The selected versions are deterministic given the same go.mod files. There’s no surprise upgrade when a new version is published.

The tradeoff: you don’t get automatic security patches or bug fixes when new versions appear. You have to upgrade explicitly. go get -u ./... updates all dependencies to their latest compatible versions; the result goes into go.mod and requires review.

The go.sum File

go.sum records cryptographic hashes of every module version used in the build:

github.com/jackc/pgx/v5 v5.5.4 h1:...
github.com/jackc/pgx/v5 v5.5.4/go.mod h1:...

Two hashes per entry: one for the module zip (the source code), one for the go.mod file. These are checked against the Go checksum database, a transparency log that records module hashes publicly.

The security guarantee: if someone modifies a published module version, the hash will change, the checksum database won’t have the new hash, and go mod verify will fail. Supply chain tampering on published versions is detectable.

Commit go.sum. It’s not a lock file in the npm sense — it doesn’t pin versions. It’s a security manifest. Omitting it from version control means you lose tamper detection.

Major Versions Are Different Modules

A module that releases v2 must change its module path:

module github.com/myorg/pricing-service/v2

All import paths within the module change to include /v2. This means v1 and v2 can be imported simultaneously — they’re different modules as far as Go is concerned.

This is the right design and also occasionally painful. Upgrading a major dependency version means updating every import path in your codebase. Tools like sed and go mod edit help. The upside: you can migrate incrementally, running v1 and v2 side by side until you’ve updated all callers.

Libraries that never change their major version despite breaking changes (this was common before module conventions firmed up) create problems: v1.10 may be incompatible with v1.5 even though the module system treats them as compatible. Check changelogs.

Workspaces for Multi-Module Development

Developing across multiple modules used to require replace directives in go.mod — clumsy and easy to accidentally commit. Go 1.18 added workspaces:

1
2
3
4
5
6
7
8
# go.work (at the root of your multi-module checkout)
go 1.21

use (
    ./pricing-service
    ./pricing-proto
    ./shared-lib
)

With go.work, the Go toolchain resolves imports across local modules without modifying their go.mod files. Commit go.work if the workspace is shared; don’t commit go.work.sum — it can be regenerated.

At the fintech startup, we ran a workspace spanning four repositories (shared types, generated proto code, two services). Previously this required a replace dance. Workspaces removed the friction significantly.

Vendor Mode

go mod vendor copies all dependencies into a vendor/ directory:

1
2
go mod vendor
# Adds vendor/modules.txt and vendor/<dependency>/ directories

Builds then use go build -mod=vendor (automatic if vendor/ exists since Go 1.14).

When to use vendor mode:

  • Air-gapped environments where the build has no internet access
  • Auditability requirements: compliance teams sometimes require the full source of dependencies to be in the repository
  • Faster CI builds: cloning the repo includes dependencies, no separate download step

When not to bother:

  • Normal development with internet access and GOMODCACHE
  • When the repository would balloon in size (vendor can be large)

The fintech startup used vendor mode because the CI environment was restricted. Once the module proxy was configured for the CI network, we dropped it — the vendor directory was large and the diffs were noisy.

The Proxy and GOPRIVATE

GOPROXY controls where the Go toolchain fetches modules from. The default is https://proxy.golang.org,direct, which fetches from Google’s module proxy (cached, fast) and falls back to direct VCS access.

Private modules need special handling:

1
2
3
# Don't proxy or sum-check private modules:
GONOSUMCHECK=github.com/myorg/*
GOPRIVATE=github.com/myorg/*

GOPRIVATE is a comma-separated list of module path prefixes that bypass the proxy and checksum database. Set this for internal modules that shouldn’t be sent to public infrastructure.

In CI, set these as environment variables. In development, add them to your shell profile or to ~/.config/go/env:

GOPRIVATE=github.com/myorg/*

Common Problems

go.sum conflicts in PRs: Two branches both add a dependency, both modify go.sum, and the merge has a conflict. Resolution: accept one side, run go mod tidy, commit the result. The toolchain regenerates go.sum correctly.

Dependency on a pre-release version: If you need a specific commit that hasn’t been tagged, use a pseudo-version:

1
2
go get github.com/somerepo/somelib@main
# go.mod gets: v0.0.0-20240115123456-abcdefabcdef

Pseudo-versions are fragile — the underlying commit can be garbage collected. Prefer tagged releases when possible.

go mod tidy removing something you need: go mod tidy removes indirect dependencies that aren’t transitively required. If a package in go.mod disappears after tidy, it either became a direct import (add the import), or it was never needed (let it go).

Diamond dependency with breaking intermediate version: Module A and Module B both require Module C, but at incompatible minor versions. MVS selects the higher of the two. If the higher version broke an API, you’ll get a compile error. Options: update your usage to the new API, or pin Module C to a specific older version (a temporary replace directive while you wait for an upstream fix).


The module system’s design is sound. The rough edges — private modules, major version upgrades, large vendor directories — are real but manageable. The core properties (reproducible builds, tamper detection via go.sum, deterministic version selection) are worth the occasional friction.