Go Modules in Practice: Beyond the Basics
Go 1.14 drops next month, and with it modules become the official default — GO111MODULE=on out of the box, no opt-out. If you're still working around GOPATH in 2020, this is the push you needed.
I've spent the last year migrating a handful of internal IBM services off GOPATH-based builds. Here's what I actually learned, not just what the docs say.
Why GOPATH Was a Problem for Teams
GOPATH worked fine if you were solo and disciplined. On a team it fell apart quickly. Every developer had to have the exact same workspace layout. You couldn't have two versions of the same dependency in your tree — which became a real issue once services started sharing libraries that evolved at different rates.
The bigger problem was reproducibility. go get with no version pinning would pull whatever HEAD was at the time of your build. Two developers cloning the same repo on different days could end up with different binaries and no easy way to diff why.
Vendor directories helped, but they were a bandaid. You were still manually managing a directory full of copied source code, and go get didn't understand any of it.
The go.mod / go.sum Workflow
The mental model is simple: go.mod declares your dependencies and their minimum versions. go.sum records the expected cryptographic hashes of those modules. You commit both files.
module github.com/ibm-internal/keyservice
go 1.14
require (
github.com/aws/aws-sdk-go v1.29.0
google.golang.org/grpc v1.27.1
go.uber.org/zap v1.14.0
)
The minimum version selection (MVS) algorithm is the thing that makes Go modules sane. When two of your dependencies both require google.golang.org/grpc but at different versions, Go picks the minimum version that satisfies both — not the latest, not a resolution algorithm that can surprise you. It's deterministic, and I can reason about it.
go.sum is not a lockfile in the npm sense. It records what the module contents should hash to, so if a module author ever changes a published version (which they're not supposed to do, but it happens), your build breaks loudly instead of silently.
replace Directives: Local Dev and Forked Dependencies
This is where modules actually shine for IBM-scale work. We fork dependencies regularly — security patches, backports, or waiting on an upstream PR that's been sitting for weeks.
For a forked dependency:
require (
github.com/some/library v1.2.3
)
replace github.com/some/library => github.com/ibm-internal/library v1.2.3-patched
For local development where you're iterating on a library alongside the service that uses it:
replace github.com/ibm-internal/cryptoutil => ../cryptoutil
That second pattern is what I use constantly. Clone both repos side by side, add the replace, and changes in cryptoutil show up immediately in the service without a publish-and-bump cycle. Just don't forget to remove it before you push — I've been burned by that.
You can also pin to a specific commit hash if the fork isn't tagged:
replace github.com/some/library => github.com/ibm-internal/library v0.0.0-20200115123456-abcdef012345
The pseudo-version format is ugly but it works, and go get will generate it for you.
go mod tidy and Why It Belongs in CI
go mod tidy does two things: adds any missing module requirements that your code actually imports, and removes requirements for packages that are no longer imported. Running it by hand before committing is good hygiene, but I make it a CI gate.
# In your CI pipeline
- name: Check go.mod is tidy
run: |
go mod tidy
git diff --exit-code go.mod go.sum
If go mod tidy changes anything, the diff will be non-empty and the build fails. This catches the surprisingly common case where someone adds a new import, runs go get, but doesn't clean up the indirect dependencies that became unnecessary. Over time those accumulate and your go.mod becomes a graveyard.
Private Modules
Private modules are where most teams hit their first real frustration. The Go checksum database (sum.golang.org) can't see your private repos, and neither can the module proxy (proxy.golang.org).
Set these environment variables, either in your shell profile or in CI:
export GONOSUMCHECK="github.com/ibm-internal/*"
export GONOSUMDB="github.com/ibm-internal/*"
export GOPRIVATE="github.com/ibm-internal/*"
GOPRIVATE is the one you actually need — it sets both GONOSUMDB and GONOPROXY as a shorthand. I still set them explicitly because I want the intent to be clear in CI configs.
For vendor mode in CI, GOFLAGS=-mod=vendor tells every go command to use the vendor directory without needing to pass the flag explicitly each time. Useful when you want reproducible builds that don't hit the network at all.
Multi-Module Repos vs Monorepo
We run a monorepo at IBM for most of our platform services. The question I get asked constantly: one go.mod at the root, or one per service?
One go.mod at the root is simpler to start. All your services share the same dependency graph, which forces consistency and prevents version drift between services. The downside is that any change to a shared library bumps every service's dependency graph.
Multiple modules in one repo (a "multi-module repo") gives you independent versioning but adds operational overhead. Every go mod tidy, every go get update has to be run per module. Internal cross-module replace directives become mandatory for local dev.
My current opinion: start with one module. Move to multi-module when you have a library that genuinely needs to version independently from the services that consume it — typically when external teams depend on it.
Updated March 2026: Go 1.18 introduced workspace mode (
go work), which solves the multi-module local development problem cleanly. Instead of addingreplacedirectives in individualgo.modfiles, you create ago.workfile at the repo root listing the modules you want to develop together. Thego.workfile should not be committed for library repos but is fine to commit for monorepos. Go 1.21 further stabilized toolchain management with thetoolchaindirective ingo.mod, letting you pin the exact Go version used to build the module. If you're on 1.21+, use that instead of relying on CI environment variables to enforce Go version consistency.
The Bits That Still Trip People Up
Don't put go.sum in .gitignore. I've seen this in three different repos. It defeats the whole point.
go get ./... in a module-aware context updates all dependencies to their latest compatible versions. That's probably not what you want in the middle of a feature branch. Be intentional.
If a test imports a package that's only present via a build tag, go mod tidy will sometimes remove it from go.mod. Use a blank import with the build tag to keep it anchored.
Finally: go mod why <module> is the command you want when you can't figure out why some transitive dependency is in your graph. It traces the import path. Use it before filing a complaint about the dependency graph.
Modules have rough edges, but they're the right foundation. The reproducibility guarantees alone are worth the migration cost.