What is Go's module graph pruning, and why does it add so many `// indirect` lines to go.mod?
answer
- the graph got smaller, the file got bigger
- one line in go.mod changed the rules
- deep tails are no longer walked
- what the walk knew must now be written down
- // indirect means nothing here imports it directly
basics
~20 sUnder a go 1.17 or higher directive the module graph keeps only each such module's immediate requirements, not its full transitive tail. In exchange, go.mod must list every module providing a transitively imported package — those are the // indirect lines.
solid answer
~50 sBefore the `go 1.17` line, the go command loaded the complete transitive module graph: every dependency's `go.mod`, and their dependencies' `go.mod` files, all the way down, just to compute the build list. Pruning changes that. When the main module's `go` directive is 1.17 or higher, the graph includes only the immediate requirements of other modules that are themselves at 1.17 or higher — the deep tails are cut off. That is only sound if the information the tails carried is available somewhere else, so the trade is that the main module's `go.mod` now lists an explicit `require` for **every** module providing a package transitively imported by its packages or tests. Those extra lines are the ones marked `// indirect`, usually in a second `require` block. The payoff is that ordinary `go build` and `go test` no longer download or read `go.mod` files for modules irrelevant to your imports.
code
mod · 13 linesmodule example.com/service
go 1.17
require (
example.com/api v1.6.0
example.com/log v1.2.0
)
require (
example.com/lib v1.4.0 // indirect
example.com/text v0.9.0 // indirect
)go deeper
Know that a require line marked // indirect just means no package of yours imports it directly, and that go mod tidy owns those lines rather than you.
Be able to state the trade in one breath: the graph stops carrying deep transitive tails, so go.mod has to carry every module behind a transitively imported package itself.
Talk about consequences in practice — faster and more stable builds, but a much longer go.mod diff that reviewers must actually read, because transitive versions move there.
Frame it as a review and policy problem: what your team requires a reviewer to check in the indirect block, and how upgrade tooling surfaces that movement instead of burying it.
## The problem pruning solves To decide which version of each dependency to use, the go command needs the requirement graph: your `go.mod` names versions, those versions' `go.mod` files name more versions, and so on. Under the original design that graph was **complete** — every requirement of every module version reachable from the main module, transitively, with no regard for whether your code imports anything from those modules. That had a real cost. A dependency you use for one small helper drags in the requirements of its test-only dependencies, and their dependencies' requirements, and so on. The go command had to fetch and parse all those `go.mod` files before it could build a single package, `go mod graph` printed a wall of edges most of which were irrelevant, and the whole build list could shift because of a module no package in your program ever touched. ## What pruning actually does When the main module's `go.mod` declares `go 1.17` or higher, the module graph is **pruned**: for each dependency module that is itself at `go 1.17` or higher, only its immediate requirements are included, not its full transitive tail. Modules still on an older `go` line are not pruned — their complete transitive requirements are loaded, because their `go.mod` files were not written under the guarantee below. ## Why that forces the `// indirect` lines The guarantee that makes pruning safe is this: a `go.mod` at 1.17 or higher must contain a `require` directive for **every** module that provides any package transitively imported by a package or test in that module. Not just the ones you import directly — all of them. So the information that used to be reconstructed by walking the whole graph is instead written down once, in the go.mod of the module that needs it. Your own `go.mod` gets the same treatment, which is why raising the directive and running `go mod tidy` produces a burst of new lines that look alarming but are pure bookkeeping. `go mod tidy` conventionally writes them into a **second `require` block**, so the first block reads as "what I import" and the second as "what my imports import". The `// indirect` comment means exactly one thing: no package in this module imports a package from that module directly. It is not a marker of being unused, and it is not a weaker kind of requirement — MVS treats an indirect require exactly like a direct one. ## What you get for it - **Fewer fetches.** For an ordinary `go build` or `go test` of packages in the main module, the go command can usually answer every version question from your own `go.mod` alone, without downloading a single dependency `go.mod`. - **A smaller graph.** `go mod graph` and `go mod why` operate on the pruned graph, so the output is closer to what actually matters. If you need to see the old shape for comparison, `go mod graph -go=1.16` reports the graph as an older Go version would load it. - **More stability.** A dependency deep in an irrelevant subtree publishing a new requirement no longer perturbs your build list. ## The friction it introduces The cost lands on review. A `go.mod` that used to be twelve lines is now sixty, and an upgrade to one direct dependency rewrites a handful of `// indirect` lines with it. Reviewers who read the first block and skip the second miss real version movement — and the second block is exactly where a transitive dependency's version is recorded, which makes it also the place you edit when you need to raise one. A second wrinkle is `go mod tidy`'s compatibility behaviour: by default it keeps the `go.sum` entries needed by the Go release *before* the one in your `go` directive, so a module at `go 1.17` still builds under 1.16. `-compat` controls that if you need to drop the older support or to check it explicitly. ## The trap to avoid Do not hand-delete `// indirect` lines to "clean up" `go.mod`. Under pruning they are load-bearing: removing one either gets restored by the next `go mod tidy` or, if you also change the go directive, quietly changes which versions get selected. Let `go mod tidy` own that block.
- Does an `// indirect` requirement carry less weight in version selection than a direct one?No. The comment is documentation for humans and for `go mod tidy`; it records that no package in this module imports that module directly. Version selection treats both kinds identically — the requirement participates exactly the same way, which is why writing an indirect require at a higher version is an effective way to raise a transitive dependency.
- Why is a dependency still on an older `go` directive not pruned?Pruning is only safe when a module's `go.mod` is guaranteed to list every module providing a transitively imported package, and that guarantee only holds for modules written at `go 1.17` or higher. For an older module the go command cannot assume the list is complete, so it falls back to loading that module's full transitive requirements.
- What is the practical downside of pruning for a reviewer?`go.mod` becomes much longer, and a single upgrade rewrites several `// indirect` lines alongside the direct one. Reviewers who only read the first require block miss transitive version movement — which is precisely where behaviour changes that nobody asked for tend to arrive.
saying these in an interview costs you the question
- Says // indirect means the requirement is unused
- Deletes // indirect lines by hand to tidy go.mod
- Thinks pruning removes dependencies from the build
- Believes indirect requires are ignored by version selection
- Claims pruning applies to every dependency regardless of its go line