Go modules: managing dependencies and versioning
1. Overview
Since Go 1.11, Go modules is the official mechanism for managing dependencies. From Go 1.16, it’s the default mode, completely replacing the old approach based on GOPATH. This article walks through the entire lifecycle of a module: initialization, adding dependencies, selecting and upgrading versions, handling major versions v2+, and working with local code or private repositories. After reading, you’ll understand what go.mod and go.sum contain. You’ll also know why Go chooses a specific version and how to fix common dependency errors.
2. Module Initialization
A module is a directory tree with a go.mod file at its root. Create a new module with go mod init:
mkdir order-service && cd order-service
go mod init github.com/nonla/order-service
The go.mod file is created:
module github.com/nonla/order-service
go 1.24
Meaning of each line:
moduleis the module path. This is the import prefix for every package within the module. If the module will be fetched by others withgo get, this path must match the repository URL.go 1.24is the minimum language version the module requires. From Go 1.21, this line is mandatory. If your toolchain is older, Go will automatically download a matching toolchain (whenGOTOOLCHAIN=auto) or report an error.
Note: For internal applications that are never imported from outside, you can use a short path like
order-service. However, using a URL-like path from the start will avoid having to change numerous imports later.
3. Adding Dependencies
3.1. Using go get
Suppose you need to generate an order ID using UUID. Add the github.com/google/uuid library:
go get github.com/google/uuid@v1.6.0
Then write code using the library:
// order/order.go
package order
import "github.com/google/uuid"
type Order struct {
ID string
Customer string
Total int64 // in Vietnamese currency
}
func NewOrder(customer string, total int64) Order {
return Order{
ID: uuid.NewString(),
Customer: customer,
Total: total,
}
}
go.mod now has an additional require block:
module github.com/nonla/order-service
go 1.24
require github.com/google/uuid v1.6.0
You can also skip the go get step: write the import first, then run go mod tidy. Go will automatically find the latest version and write it to go.mod.
3.2. The go.sum File
At the same time, Go creates the go.sum file:
github.com/google/uuid v1.6.0 h1:<hash of module content>
github.com/google/uuid v1.6.0/go.mod h1:<hash of go.mod file>
Each dependency has two lines. The first is the hash of the complete module content, the second is the hash of its go.mod file alone. When fetching a module, Go compares the actual hash with go.sum. It also cross-checks with the public checksum database (sum.golang.org). If anyone modifies the content of a released tag, the build fails instead of silently using unfamiliar code.
Note: Always commit both
go.modandgo.sum. Don’t addgo.sumto.gitignore. If this file is missing, CI machines cannot verify the dependencies.
3.3. Direct and Indirect Dependencies
Add testify for writing tests:
go get github.com/stretchr/testify@v1.10.0
go.mod will have two require blocks:
require (
github.com/google/uuid v1.6.0
github.com/stretchr/testify v1.10.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
The comment // indirect marks modules that your code doesn’t import directly but are needed by another dependency. Go writes them to go.mod to make builds completely reproducible. This way, Go doesn’t need to re-read each dependency’s go.mod to build the graph.
4. Versions and How Go Chooses Versions
4.1. Semantic Versioning
Go modules use semantic versioning in the form vMAJOR.MINOR.PATCH:
PATCH(v1.6.0 → v1.6.1): bug fixes, no API changes.MINOR(v1.6.0 → v1.7.0): new features, still backwards compatible.MAJOR(v1 → v2): breaking changes.
A version must be a git tag with the v prefix. If you point to a commit without a tag, Go creates a pseudo-version:
go get github.com/google/uuid@0f3b7a2
github.com/google/uuid v1.6.1-0.20240815101530-0f3b7a2c1d4e
A pseudo-version consists of three parts: the base version, UTC timestamp of the commit, and the first 12 characters of the hash. Thanks to this, pseudo-versions still sort correctly by time.
4.2. Minimal Version Selection
This is the biggest difference from npm or Maven. Go uses the Minimal Version Selection (MVS) algorithm. For each module, Go selects the highest version among those required, not the latest version that exists.
For example, your project has two dependencies:
order-service
├── payment-sdk → require github.com/google/uuid v1.3.0
└── shipping-sdk → require github.com/google/uuid v1.5.0
Even if uuid v1.6.0 has been released, Go still selects v1.5.0. The reason: it’s the lowest version that satisfies all requirements. Consequences:
- Builds are reproducible. A library releasing a new version doesn’t automatically change your build.
- No need for a separate lock file.
go.modis enough to determine versions. - To use a newer version, you must upgrade explicitly.
See which versions were actually selected with the command:
go list -m all
github.com/nonla/order-service
github.com/davecgh/go-spew v1.1.1
github.com/google/uuid v1.5.0
...
4.3. Upgrading and Downgrading
Check which dependencies have newer versions:
go list -m -u all
github.com/google/uuid v1.5.0 [v1.6.0]
github.com/stretchr/testify v1.10.0
The version in brackets is the latest version you can upgrade to. Common upgrade commands:
go get github.com/google/uuid@latest # latest version
go get github.com/google/uuid@v1.5.0 # to a specific version (may be downgrade)
go get -u=patch ./... # upgrade only PATCH for all dependencies
go get -u ./... # upgrade MINOR + PATCH for all dependencies
go get github.com/google/uuid@none # remove dependency entirely
Note:
go get -u ./...also upgrades indirect dependencies. The diff can be very large. On production projects, you should upgrade each module, run tests, then commit.
5. Major Version v2 and Above
Go enforces the import compatibility rule: two versions with the same import path must be backwards compatible. Therefore, from v2 onwards, the major version is part of the module path.
For example, with the router chi:
go get github.com/go-chi/chi/v5@v5.1.0
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
)
func main() {
r := chi.NewRouter()
r.Get("/orders", func(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte("list of orders"))
})
http.ListenAndServe(":8080", r)
}
The suffix /v5 is part of the import path. The package name is still chi, so you call chi.NewRouter() as usual. Because chi/v4 and chi/v5 have different paths, both versions can coexist in one binary. This helps you migrate gradually, part by part.
If you’re a library author and want to release v2:
module github.com/nonla/pricing/v2
go 1.24
You change the module line to a path with the /v2 suffix, update internal imports, then tag it v2.0.0.
Note: A common mistake is tagging
v2.0.0without changing the module path. Thengo get github.com/nonla/pricing@v2.0.0will error withinvalid version: module contains a go.mod file, so module path must match major version.
6. Useful go mod Commands
go mod tidy # add missing modules, remove unused ones
go mod why -m gopkg.in/yaml.v3 # why this module is present
go mod graph # print the entire dependency graph
go mod verify # verify local cache matches go.sum
go mod download # pre-download dependencies (useful for Docker layers)
Example output of go mod why:
# gopkg.in/yaml.v3
github.com/nonla/order-service/order
github.com/stretchr/testify/assert
gopkg.in/yaml.v3
Read from top to bottom: package order (in tests) imports testify/assert, which in turn imports yaml.v3.
Run go mod tidy before each commit. In CI, you can check if go.mod is tidy:
go mod tidy -diff
From Go 1.23, the -diff flag prints the difference and returns a non-zero exit code if the file is not tidy. This command doesn’t modify the file.
7. Working with Local Code: replace and go.work
7.1. The replace Directive
Suppose you’re fixing the pricing library in an adjacent directory and want order-service to use the version being fixed immediately:
require github.com/nonla/pricing v1.2.0
replace github.com/nonla/pricing => ../pricing
Go will read code from ../pricing instead of fetching from the network. replace can also point to a fork:
replace github.com/nonla/pricing => github.com/minh/pricing v1.2.1-fix
Note:
replaceonly takes effect in the main module. If your module is a library, its users won’t see yourreplacedirectives. Also, don’t commitreplacedirectives pointing to local paths. Builds on CI will break because the../pricingdirectory doesn’t exist there.
7.2. Workspace with go.work
From Go 1.18, a workspace is a cleaner way to develop multiple modules at once:
go work init ./order-service ./pricing
The go.work file is created:
go 1.24
use (
./order-service
./pricing
)
When running go build or go test in a workspace, any import of github.com/nonla/pricing will use local code. You don’t need to modify go.mod. Typically, go.work is not committed; each developer creates it based on their directory organization.
7.3. exclude and retract
exclude is used in the main module to exclude a specific version:
exclude github.com/google/uuid v1.4.0
retract is declared by library authors to indicate that a released version should not be used:
retract v1.2.0 // calculated VAT incorrectly for international orders
After you release a new version containing this line, go get @latest will skip v1.2.0. The go list -m -u command will also warn anyone using that version.
8. Private Modules and Proxy
By default, Go fetches modules via GOPROXY=https://proxy.golang.org,direct and verifies checksums via sum.golang.org. With internal repositories, public proxies cannot access them, and you don’t want the module name to leak outside. Declare:
go env -w GOPRIVATE=github.com/nonla/*,gitlab.nonla.internal/*
Modules matching patterns in GOPRIVATE will be fetched directly via git and skip the checksum database. You still need to configure authentication for git, for example via ~/.netrc or SSH:
git config --global url."git@github.com:".insteadOf "https://github.com/"
If your company has its own proxy (Athens, Artifactory…), put that proxy at the beginning of GOPROXY:
go env -w GOPROXY=https://goproxy.nonla.internal,https://proxy.golang.org,direct
9. Testing Behavior with Dependencies
Write a test for NewOrder using testify added in section 3.3:
// order/order_test.go
package order
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewOrder_GeneratesValidUniqueID(t *testing.T) {
first := NewOrder("Nguyen Van An", 250_000)
second := NewOrder("Tran Thi Binh", 120_000)
_, err := uuid.Parse(first.ID)
require.NoError(t, err)
assert.NotEqual(t, first.ID, second.ID)
assert.Equal(t, "Nguyen Van An", first.Customer)
assert.EqualValues(t, 250_000, first.Total)
}
Run the test:
go test ./...
ok github.com/nonla/order-service/order 0.004s
The test verifies three things: ID is a valid UUID, the two orders have different IDs, and the other fields are assigned correctly. require.NoError stops the test immediately if ID can’t be parsed. assert records the error and continues execution. The first time on a new machine, Go will fetch dependencies at the exact versions in go.mod and verify hashes with go.sum before compiling.
10. Common Errors
missing go.sum entry for module providing package:go.sumis missing the corresponding entry, usually because someone editedgo.modmanually. Fix: rungo mod tidy.checksum mismatch: module content doesn’t match the hash. Trygo clean -modcache. If it still fails with a public module, treat this as a serious issue and don’t delete the line ingo.sumto get around it.go: updates to go.mod needed: CI typically runs with-mod=readonly, whilego.modhasn’t been tidied. Rungo mod tidylocally, then commit again.- Wrong major version import: using
github.com/go-chi/chiinstead of.../chi/v5. You’ll get an old v1 version or a strange pseudo-version. Check the import path first. - Overusing
vendor/:go mod vendoris still useful for completely offline builds. Otherwise, proxy combined withgo.sumalready ensures reproducibility, so you don’t need to commit thousands of vendor files.
11. Conclusion
Go modules solve the dependency problem with a few simple ideas. go.mod declares requirements, go.sum ensures integrity, and Minimal Version Selection makes builds reproducible without a lock file. The major version sits right in the import path, so breaking changes are always visible in the code.
For daily work, you only need to remember four habits:
- Use
go get module@versionto deliberately add or upgrade dependencies. - Run
go mod tidybefore committing. - Use
go.workinstead ofreplacewhen developing multiple modules at once. - Configure
GOPRIVATEfor internal repositories.