# Dependency hygiene for Go modules

> Source: https://learn-go.org/review/dependencies/
> Part of Learn Go, free to read.

Go has the best default supply-chain story of any language on this network, and it is worth knowing why before looking at what can still go wrong.

- **`go.sum` is mandatory and checked.** Every module version is pinned by hash and verified on every build.
- **The checksum database** cross-checks those hashes against a public transparency log, so a module author cannot quietly change a published version.
- **Minimal version selection** means you get the lowest version that satisfies all requirements, not the newest. Builds do not change under you because someone published a release.
- **`govulncheck` does reachability analysis**, reporting only vulnerabilities your code can actually reach.

That removes most of the attack surface that [dominates npm and PyPI](https://learn-python.com/review/dependencies/). What is left is smaller and more specific.

## Hallucinated import paths

Models invent module paths, and Go's are compositional enough to invent convincingly:

```go
import (
    "github.com/gorilla/websocket"        // real
    "github.com/gin-gonic/gin/middleware" // plausible subpackage, does not exist
    "github.com/uber-go/zap"              // wrong path: it is go.uber.org/zap
    "golang.org/x/exp/maps"               // moved into the stdlib; may not be what you want
)
```

Three flavours worth recognising:

- **Wrong vanity path.** `go.uber.org/zap`, not `github.com/uber-go/zap`. Both look right.
- **Invented subpackage.** The module exists; that path within it does not.
- **Stale location.** A package that moved from `golang.org/x/exp` into the standard library, or between major versions.

The good news: `go build` fails immediately and cheaply. Unlike npm or pip, **nothing gets installed and no code executes** during resolution — Go modules have no install scripts, which removes the entire slopsquatting execution vector.

The bad news: someone can still register a plausible module path and wait. The check is the same as everywhere — before adding a dependency you did not choose deliberately, look at the repository: does it have history, other users, and issues from real people?

```bash
go list -m -json github.com/some/module@latest   # origin, version, checksum
```

## The directives that disable the guarantees

Three things in `go.mod` and the environment quietly turn off the protections above. All three have legitimate uses; all three deserve a comment.

### `replace`

```go
replace github.com/upstream/lib => ../local-fork
replace github.com/upstream/lib => github.com/ourfork/lib v1.2.3
```

A local `replace` pointing outside the repository makes your build non-reproducible for anyone else. A `replace` to a fork is fine and should say why, with a link to the upstream issue and a plan to remove it.

**Catch it with:** `grep -n '^replace' go.mod` in review. Note that `replace` directives in a dependency's `go.mod` are ignored — they only apply in the main module — which surprises people.

### `GOFLAGS=-mod=mod` and `GONOSUMDB`/`GOPRIVATE`/`GONOSUMCHECK`

```bash
GOPRIVATE=github.com/yourorg/*     # skips proxy AND checksum db for these paths
GONOSUMDB=*                        # skips checksum verification. never do this.
```

`GOPRIVATE` is correct and necessary for internal modules. A wildcard that disables checksum verification broadly is not — and it is a common "fix" for a confusing build error.

### `// indirect` accumulation

`go mod tidy` keeps this honest. A `go.mod` that has not been tidied accumulates requirements nothing uses, which inflates your vulnerability surface for no benefit.

```bash
go mod tidy && git diff --exit-code go.mod go.sum   # in CI: fails if not tidy
```

## Reduce the surface

Go's standard library is large, and generated Go reaches for dependencies it does not need — partly because a lot of pre-2021 Go in the training data predates the stdlib additions.

| Generated reaches for | Often unnecessary because |
|---|---|
| `logrus`, `zap` | `log/slog` is stdlib since 1.21 |
| `github.com/pkg/errors` | `fmt.Errorf` with `%w`, `errors.Is/As` |
| `gorilla/mux`, `chi` for simple routing | `net/http` has method and wildcard patterns since 1.22 |
| a `min`/`max`/`contains` helper | builtins and `slices` |
| `github.com/google/uuid` for a random id | `crypto/rand` plus encoding, if you do not need UUID format |
| `testify` | the stdlib `testing` package plus `cmp.Diff` |
| `godotenv` | read the file, or use your platform's config |

`testify` is worth singling out because it is nearly universal and genuinely optional: table-driven tests with `cmp.Diff` for comparison cover almost everything, with one fewer dependency and no assertion DSL to learn.

Put the rule in [your `AGENTS.md`](/ai/agents-md/): *new dependencies need a sentence justifying them; prefer the standard library.*

## Read the go.sum diff

```bash
go get github.com/some/lib
git diff go.mod go.sum
```

A one-line `go.mod` change is often a forty-line `go.sum` change. That diff is the transitive dependencies you just accepted, and it is the only place you see them. A dependency that pulls in twenty modules for one function is a decision, not an accident.

```bash
go mod graph | wc -l                        # how big is the graph?
go mod why github.com/surprising/module     # why is this here at all?
```

`go mod why` is the tool for the "where did *that* come from" moment, and it gives you the actual import chain.

## Keep it current

```bash
govulncheck ./...                           # in CI, on every build
go list -m -u all                           # what has newer versions
go get -u ./... && go mod tidy && go test ./...
```

`govulncheck` in CI is the highest-value item here. Because it does reachability analysis, a failure means something real, so people fix it rather than muting it — which is the failure mode of noisier scanners in other ecosystems.

Enable Dependabot or Renovate as well. Not because each update matters individually, but because a repository where updates arrive continuously is one where a security update can be merged in an afternoon rather than being a project.

## Vendoring

```bash
go mod vendor        # commits the dependency source into vendor/
```

Worth it when you need builds that work with no network, or an auditable snapshot of exactly what ships. The cost is a large diff on every update and a `vendor/` directory people scroll past in review. For most projects, `go.sum` plus a module proxy gives you the same guarantees with less noise.

:::verdict The whole policy
1. `go mod tidy` clean, enforced in CI.
2. `govulncheck` in CI. Act on it; it is low-noise by design.
3. Read the `go.sum` diff when adding anything.
4. Every `replace` has a comment saying why and when it goes.
5. Never disable checksum verification to make an error go away.
:::

## Common questions

### Can a Go module run code at install time?

No. There is no install or post-install hook — `go get` downloads and verifies source, and nothing executes until you build and run. That removes the single most dangerous property of npm and PyPI installs, and it is why package-install permission matters less in Go than elsewhere. Build-time code generation via `go:generate` is explicit and only runs when you ask.

### Should package installs still go behind a confirmation for my agent?

Less critical than in other ecosystems, but still worth it — not for the execution risk, which is absent, but because it makes you read the module path before it enters `go.mod`. Two seconds, and it catches the wrong-vanity-path case.

### What does minimal version selection mean in practice?

You get the lowest version that satisfies every requirement in the graph, so adding a dependency does not silently upgrade your others, and builds are reproducible without a lockfile ceremony. Upgrades are explicit acts via `go get`, which is the behaviour you want.

### Is a private module proxy worth running?

For a team with internal modules, yes — it gives you availability independent of upstream, a cache, and a single place to enforce policy. For a solo project, `GOPRIVATE` pointed at your VCS is enough.
