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:

  • module is the module path. This is the import prefix for every package within the module. If the module will be fetched by others with go get, this path must match the repository URL.
  • go 1.24 is 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 (when GOTOOLCHAIN=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.mod and go.sum. Don’t add go.sum to .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.mod is 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.0 without changing the module path. Then go get github.com/nonla/pricing@v2.0.0 will error with invalid 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: replace only takes effect in the main module. If your module is a library, its users won’t see your replace directives. Also, don’t commit replace directives pointing to local paths. Builds on CI will break because the ../pricing directory 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.sum is missing the corresponding entry, usually because someone edited go.mod manually. Fix: run go mod tidy.
  • checksum mismatch: module content doesn’t match the hash. Try go clean -modcache. If it still fails with a public module, treat this as a serious issue and don’t delete the line in go.sum to get around it.
  • go: updates to go.mod needed: CI typically runs with -mod=readonly, while go.mod hasn’t been tidied. Run go mod tidy locally, then commit again.
  • Wrong major version import: using github.com/go-chi/chi instead of .../chi/v5. You’ll get an old v1 version or a strange pseudo-version. Check the import path first.
  • Overusing vendor/: go mod vendor is still useful for completely offline builds. Otherwise, proxy combined with go.sum already 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@version to deliberately add or upgrade dependencies.
  • Run go mod tidy before committing.
  • Use go.work instead of replace when developing multiple modules at once.
  • Configure GOPRIVATE for internal repositories.
golanggo modulesdependencyversioning