Go modules and versioning: practical tips and trade-offs
Dependency management can make or break a production system. Go modules and versioning: practical tips and trade-offs gives you the tooling and discipline to keep your codebase stable, reproducible, and secure. This guide walks you through the real-world decisions we make every day when managing dependencies across trading platforms, SaaS products, and high-traffic APIs.
Why dependency management matters in production
When you deploy a service to production, you need confidence that the exact same code runs every time. Without proper versioning, a teammate might pull a newer (and broken) version of a dependency weeks after you wrote your code. Go's module system solves this by locking versions in go.mod and checksums in go.sum, giving you reproducible builds across machines and CI pipelines.
We've seen transitive dependencies (libraries that your libraries depend on) cause silent failures because someone's local Go cache had a different version than production. Proper versioning prevents that entirely.
go.mod and go.sum — Declared versions and checksums
The go.mod file declares your direct dependencies and their versions. The go.sum file holds cryptographic hashes of those dependencies, ensuring nobody can swap out a library for a malicious copy.
Here's what a real go.mod looks like in a production service:
module github.com/acme/trading-api
go 1.22
require (
github.com/lib/pq v1.10.9
github.com/redis/go-redis/v9 v9.0.5
github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1
)
require (
github.com/google/uuid v1.5.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
)The require block at the top holds direct dependencies—libraries you explicitly import. The require block below (often collapsed) lists transitive dependencies that came from your direct dependencies. Those marked // indirect are brought in by others, not by you.
When you run go mod tidy, Go fetches the modules and records their hashes in go.sum:
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQsRKUsx1T2ocqPZsY=
github.com/lib/pq v1.10.9/go.mod h1:W7q37aWSuqVzxCr2oM6g2onrrM2GAUv3d76y4Q7kCI=
github.com/redis/go-redis/v9 v9.0.5 h1:CuQcn5HIEeK7BgElubPP8CGtE+RojsZ/rTiIWrPSlw=Each line pairs a package with its hash. If someone tampers with the code on a remote server, the hash won't match and your build fails. This is your first line of defense.
Practical guidance: Always commit both go.mod and go.sum to version control. Never ignore go.sum. If you see a hash mismatch error locally, run go mod verify to diagnose it—usually it's a network glitch or a stale cache.
Update dependencies — go get -u and go mod tidy
Dependencies need updates for features, performance, and—critically—security patches. We use a few commands in our workflow:
go get -uupgrades direct dependencies to their latest minor/patch versions.go get -u ./...updates everything in your module and its subdirectories.go mod tidyremoves unused dependencies and adds missing ones, then reconcilesgo.sum.
Here's how we handle updates in a mature e-commerce platform:
// Check what's available
go list -u -m all
// Upgrade a specific package
go get -u github.com/redis/go-redis/v9
// Upgrade everything and clean up
go get -u ./...
go mod tidygo list -u -m all shows each dependency, its current version, and the latest available version. This gives you a risk assessment—if a patch release is available, it's usually safe to take. If only a minor or major version bump exists, you might want to review the changelog first.
In a trading system where latency matters, we once found that upgrading to a newer version of the gRPC library cut tail latencies by 15% without changing our code. On the flip side, we've delayed major version upgrades for three months until we had time to refactor imports and handle breaking changes.
Common practice in SaaS teams: Run go get -u ./... weekly on a feature branch, run your full test suite, and merge if everything passes. Automate this with Dependabot or Renovate to catch security fixes early.
Major versions — v2+ modules have /v2 in the import path
Go enforces a strict rule: if a library reaches version 2.0 or higher, the import path must include the major version. This prevents your code from breaking silently when a library makes incompatible changes.
When you upgrade from go-redis v8 to go-redis v9, the import path changes:
// Old: v8
import "github.com/go-redis/redis/v8"
// New: v9
import "github.com/redis/go-redis/v9"Your compiler won't let you mix v8 and v9 in the same binary. This sounds restrictive, but it's brilliant: it prevents transitive dependency chaos. If your payment library depends on v8 and your cache layer depends on v9, you'll get a clear error instead of undefined behavior at runtime.
Let's say you're migrating a ride-hailing backend from v1 to v2 of a critical library. You'd do this step-by-step:
// Start: still on v1
go get github.com/yourorg/ride-lib/v2
// Update one file at a time
// OLD:
import "github.com/yourorg/ride-lib"
// NEW:
import "github.com/yourorg/ride-lib/v2"
// Fix compilation errors (API may have changed)
// Test thoroughly
// Commit and deploy
// Once fully migrated, remove v1:
go mod tidyThis phased approach lets you catch issues early and keep your codebase working at each step. In a large monorepo, we've done this migration in parallel across multiple packages, testing each one independently.
Trade-off to consider: Major version bumps are friction. But they're friction with a purpose—they force deliberate decisions instead of silent breaks. We'd rather spend a day migrating a library than debug a memory leak caused by two incompatible versions of the same library running side-by-side.
Vulnerabilities — govulncheck finds known issues in your code paths
govulncheck scans your code and dependencies for known vulnerabilities. Unlike generic dependency scanners that flag every package with a CVE, govulncheck only reports vulnerabilities that your code actually uses.
Install it with:
go install golang.org/x/vuln/cmd/govulncheck@latest
// Run it
govulncheck ./...Output looks like:
Vulnerability #1: GO-2023-1840
Fixed in: github.com/hashicorp/vault@v1.13.1
Found in: github.com/hashicorp/vault@v1.13.0
This is an example description of a vulnerability.
For more information, visit https://pkg.go.dev/vuln/GO-2023-1840
Your code imports the vulnerable code path in main.go:line 42The key insight: "Your code imports the vulnerable code path." This is the difference between noise and actionable intelligence. If you import a package but never call the vulnerable function, govulncheck won't report it.
In a CRM platform we maintain, we had a JSON parser library with a DoS vulnerability. govulncheck showed the vulnerability existed, but after tracing through our imports, we found we never called the affected function. We still patched it within 30 days for hygiene, but we didn't treat it as an emergency production issue.
Run govulncheck in your CI pipeline on every pull request:
// .github/workflows/vuln-check.yml
name: Vulnerability Check
on: [pull_request, push]
jobs:
vulncheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v4
with:
go-version: '1.22'
- run: go install golang.org/x/vuln/cmd/govulncheck@latest
- run: govulncheck ./...This catches vulnerabilities before they merge to main. Pair it with weekly go get -u runs to stay current on patches.
Common mistakes
Committing vendor/ without go.sum: If you vendor dependencies, you still need go.sum in version control. Never assume vendor/ is your source of truth.
Upgrading without testing: A minor version bump is not risk-free. Always run your test suite after upgrading. We've caught subtle breaking changes in "patch" releases that came from upstream maintenance decisions.
Ignoring indirect dependencies: Don't ignore those // indirect comments in go.mod. Use go mod why -m github.com/some/package to understand why a transitive dependency is there. Sometimes a deeply nested dependency is bringing in security risk.
Running govulncheck only once: Run it in CI, not just locally. A developer might skip it, but CI never forgets. Make it a required check before merge.
Not reading changelogs for major versions: Between v1 and v2, APIs change. A changelog takes 10 minutes; a migration without one takes hours. Read it first.
Key takeaways
go.modandgo.sumtogether create reproducible, verifiable builds. Commit both to version control.- Use
go list -u -m allto assess available upgrades, thengo get -uand test before deploying. - Major versions (v2+) change import paths on purpose—this prevents silent conflicts when transitive dependencies diverge.
govulncheck ./...reports only vulnerabilities your code actually uses. Run it in CI on every PR.- Dependency management is a continuous process. Weekly updates and security monitoring prevent accumulation of risk.
- Trade convenience for safety: a day spent migrating to v2 beats a week debugging production issues from mixing incompatible versions.






Comments
Loading comments…