Dev Skills

Modules, dependencies, and the go command

How Go finds your code and other people’s, and the commands worth memorising.

Topic 49 of 98 · 5 min to read · 50 min to build
After this topic you can
  • Create a module with go mod init and explain what go.mod and go.sum each record.
  • Explain why Minimal Version Selection picks the oldest version that satisfies every requirement.
  • Run go vet, gofmt and go test -race the way CI would.

What a module is, and what the go command does

A module is one project: a folder with a go.mod file at the top, containing one or more packages. go.mod records the module's name, the Go version it needs, and every outside package it depends on.

A dependency is a package written by someone else that your code imports. The import path starts with where the code lives, such as github.com/jackc/pgx. By default the go command downloads it through the Go module mirror (proxy.golang.org), which fetches from that repository. There is no registry you have to publish to.

The go command is the single tool that does everything: builds, tests, formats, fetches dependencies, and reads documentation. This topic covers the parts of it you will use weekly, and the four checks worth running before every push.

go.mod and go.sum

go.mod
module github.com/you/snapnotes
go 1.25
require (
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/jackc/pgx/v5 v5.7.5
golang.org/x/crypto v0.43.0
)
require (
github.com/jackc/puddle/v2 v2.2.2 // indirect
)
Direct dependencies in the first block, transitive ones marked // indirect. Commit both go.mod and go.sum.
  • `go.mod` declares the module path, the minimum Go version, and your dependencies.
  • `go.sum` holds cryptographic checksums of every module version you use. If a download ever differs from what was recorded, the build fails. Commit it.
  • The `go` line matters. It gates language behaviour: a module declaring go 1.21 keeps the old per-loop variable semantics even on a newer toolchain.

Minimal Version Selection

Many package managers resolve to the *newest* version that satisfies everyone. Go picks the oldest version that satisfies everyone: the highest of the minimums that your modules ask for. That sounds backwards until you notice the consequence: your build does not change because someone else published a release. Upgrades only happen when you ask for one.

This is why Go needs no lockfile. go.mod plus MVS is already deterministic.

The commands

terminal
go mod init github.com/you/proj # start a module
go get example.com/pkg@latest # add or upgrade
go get example.com/pkg@v1.2.3 # pin an exact version
go get example.com/pkg@none # remove
go mod tidy # add what is missing, drop what is unused
go mod why -m example.com/pkg # why is this in my build?
go mod graph # the full dependency graph
go mod verify # do the downloaded modules match go.sum?
go mod vendor # copy deps into ./vendor (for air-gapped builds)
go build ./... # compile everything
go run ./cmd/api # compile and run
go test ./... # test everything
go vet ./... # find real bugs
go doc net/http.ServeMux # read the docs offline
go clean -modcache # nuclear option when a download is corrupt
Run `go mod tidy` before every commit. It is the gofmt of dependencies.

Workspaces, for working on two modules at once

terminal
go work init ./api ./sharedlib
go work use ./anothermodule
# now ./api's import of sharedlib resolves to your LOCAL copy,
# with no replace directive polluting go.mod
go.work is local development state. Add it to .gitignore — do not commit it.

Build flags worth knowing

terminal
CGO_ENABLED=0 go build -ldflags="-s -w" -o api ./cmd/api # static, stripped
go build -ldflags="-X main.version=$(git rev-parse --short HEAD)" ./cmd/api
GOOS=linux GOARCH=arm64 go build ./cmd/api # cross-compile, no toolchain needed
go tool dist list # every supported target
go build -race ./... # binary with race checks built in
go build -gcflags='-m' ./... 2>&1 | head # what escaped to the heap

The tools that belong in CI

.github/workflows/ci.yml
- run: go build ./...
- run: test -z "$(gofmt -l .)" # fails if anything is unformatted
- run: go vet ./...
- run: go test -race -coverprofile=c.out ./...
- run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...
  • `gofmt`: not a preference. With the CI line above, unformatted code fails the build.
  • `go vet`: ships with Go, and its checks are chosen to have very few false positives. Run it always.
  • `govulncheck`: from the Go team, and low-noise because it reports vulnerabilities only in functions your code can reach.
  • `staticcheck`: a widely used third-party linter that finds real bugs vet misses. Add it once vet is clean.
  • `golangci-lint`: a runner for many linters. Enable a small set, not sixty, or you will spend your time writing //nolint.
Note: Go carries the Go 1 compatibility promise: code written for Go 1 is meant to keep compiling and working on later releases. Upgrading the toolchain is normally a version bump and nothing else. That is why the usual answer to "what Go version should I use" is the latest release. Only the two newest releases get security fixes.

Publishing a package

  • The module path is the repository URL: github.com/you/thing.
  • Tag a release with a semver tag: git tag v1.0.0 && git push --tags. That is the whole publishing process. There is no registry to upload to.
  • Breaking change? The major version goes in the path: github.com/you/thing/v2. That way v1 and v2 can coexist in one build.
  • Before v1.0.0 you are allowed to break things. After it, a breaking change needs a new major version.
The short version
  • Commit go.mod and go.sum. Minimal Version Selection makes builds reproducible with no lockfile.
  • go mod tidy before every commit; go work for developing two modules together.
  • CGO_ENABLED=0 plus GOOS/GOARCH cross-compiles to anything with no toolchain.
  • CI runs gofmt, vet, test -race, and govulncheck. That is the whole gate.
Remember: The module path is a URL, publishing is a git tag, and a breaking change means a new path with /v2 on the end.
Build this before moving on
Run go mod why on each of your dependencies and find out what actually pulls each one in. Then run go mod tidy and check whether anything disappears.
Then try these in your own editor
  1. 1Run go mod why -m on each dependency and find out what actually pulls each one in.
  2. 2Run go mod tidy and check whether anything disappears.
  3. 3Set up a go.work linking two local modules and edit one from the other with no replace directive.
  4. 4Add the four CI commands to a Makefile target called check and run it before your next push.
Check yourselfTake the Modules and packages test