Module Graph Pruning — Interview Questions¶
Practice questions ranging from junior to staff-level. Each has a model answer, common wrong answers, and follow-up probes.
Junior¶
Q1. What is module graph pruning?¶
Model answer. It is a Go 1.17 change to how much of the module graph the go command loads. The module graph is every module reachable through require edges, each contributing its own go.mod. Before 1.17, Go loaded the full transitive closure — every dependency's requirements, including deep, never-imported ones. With pruning, a main module at go 1.17+ loads only the part of the graph relevant to building and testing it: its direct dependencies plus one level of those dependencies' requirements, following the import graph. The deep tails are pruned away, making go commands faster.
Common wrong answers. - "It removes unused dependencies." (No — that is go mod tidy. Pruning trims what Go loads, not what you depend on.) - "It makes my binary smaller." (No — it speeds up go commands; the binary is unchanged.) - "It changes which versions I get." (No — pruning preserves MVS's selected versions.)
Follow-up. What turns pruning on? — The go directive in go.mod: go 1.17 or higher gives a pruned graph; go 1.16 or lower gives the full graph.
Q2. Why did go.mod get bigger in Go 1.17?¶
Model answer. Because a pruned go.mod must be self-contained. Under the full graph, Go loaded the deep graph at build time to find indirect dependency versions. Pruning deliberately does not load that deep graph, so those indirect requirements must be recorded in your go.mod instead. go mod tidy writes them as // indirect lines, conventionally in a second require block. So the larger file is the information pruning removed from the loadable graph, written down where Go always reads it.
Common wrong answer. "The indirect block is bloat I should delete." (No — those entries keep the pruned graph self-contained and correct.)
Follow-up. Should you ever hand-edit the indirect block? — No. It is generated by go mod tidy.
Q3. What is the difference between the two require blocks in a modern go.mod?¶
Model answer. The first block lists direct dependencies — modules whose packages your own code imports. The second block lists indirect dependencies, each marked // indirect — modules you do not import but that your dependencies need, recorded so the pruned go.mod is self-contained. The split is a readability convention produced by go mod tidy; a single block with // indirect comments would be equivalent.
Follow-up. How do you find out why an indirect module is present? — go mod why -m <module>.
Q4. Does pruning change which versions of my dependencies I use?¶
Model answer. No. Pruning changes how much of the graph Go loads, not which versions MVS selects. The pruned graph is constructed so that MVS picks the same version for every imported package as it would over the full graph. Same build list, computed faster. (There are rare deepening situations that surface constraints, but those reflect constraints that were always logically present.)
Follow-up. So what is the benefit? — Faster go commands, a smaller and more legible graph, fewer go.mod fetches, and a self-contained go.mod.
Q5. How do I enable pruning on an old project?¶
Model answer. Bump the go directive to 1.17 or higher and re-tidy:
go mod tidy rewrites go.mod for the pruned regime, adding the larger indirect set. In 2026 you would typically go straight to a modern version like go 1.21. Commit the (large) go.mod/go.sum diff as its own change.
Follow-up. Why isolate it in its own commit? — The directive bump produces a big go.mod diff; isolating it keeps that noise out of feature reviews and makes bisecting cleaner.
Middle¶
Q6. What exactly is in the pruned graph? Is it just direct dependencies?¶
Model answer. More than direct dependencies. The pruned graph contains: the main module; every module providing a package transitively imported by the main module's packages or tests; and, for each such module at go 1.17+, the modules named in its require directives — one level deep. It does not recursively expand the requirements of a module unless that module also supplies an imported package. So it is "the import-relevant subgraph plus one level of those modules' stated requirements," not merely the direct block.
Follow-up. What about a dependency at go 1.16? — It is not self-contained, so Go loads its full transitive requirements, partially re-inflating the graph.
Q7. What is lazy module loading and how does it relate to pruning?¶
Model answer. Lazy loading is pruning's companion feature, also from Go 1.17. The goal: most go commands should answer from the main module's go.mod alone, without loading even the pruned graph. Because a pruned go.mod records all the indirect requirements needed to resolve imported packages, the toolchain can often resolve a build from go.mod plus the few go.mod files it must read, loading the broader graph only when an operation genuinely needs it (like go mod graph or go mod tidy). Pruning makes the graph small; lazy loading avoids loading even that small graph unless necessary.
Follow-up. Which operations force a full-graph load? — go mod graph, go mod tidy, go get that affects pruned requirements, and any build involving a go ≤ 1.16 dependency.
Q8. What does go mod tidy -compat do?¶
Model answer. -compat=<version> makes tidy keep the module buildable by an older Go version. It (1) verifies the pruned build list agrees with the build list that older version would compute under its full-graph regime, reporting an error if they disagree, and (2) retains the extra go.sum entries — notably go.mod hashes — that the older version needs to load its graph. For a go 1.17+ module, -compat defaults to one minor version below the directive (a go 1.17 module defaults to -compat=1.16).
Common wrong answer. "It downgrades my module to the old Go version." (No — it keeps the current pruned regime while preserving compatibility metadata for an older consumer.)
Follow-up. When must you set it explicitly? — When you publish a library and support consumers on a specific older Go version below the default.
Q9. How does go mod graph output change across the 1.16/1.17 boundary?¶
Model answer. At go 1.17+, go mod graph prints the pruned graph — substantially fewer edges, because the deep, never-imported requirement subtrees are removed. At go 1.16, it prints the full graph — every transitive require edge. The cleanest way to see pruning is to compare go mod graph | wc -l at the two directives for the same project; the pruned count is dramatically smaller.
Follow-up. Does go mod graph ever load the full graph even on a pruned module? — Yes; to print the complete graph it may load more than a build would.
Q10. Are your dependencies' test dependencies in the pruned graph?¶
Model answer. No. The pruned graph includes the modules needed to build and test your module's packages — including your _test.go imports. But it excludes the test-only dependencies of your dependencies, because you never run their tests. This is exactly the bloat pruning removed: the full graph dragged in every transitive module's test dependencies; pruning cuts that.
Follow-up. Can you run an upstream dependency's tests from your consumer module? — Generally no, and pruning is one reason its test-only deps are absent.
Q11. A one-line import change produced a multi-line go.mod diff. Bug?¶
Model answer. No — that is graph deepening. Adding an import can reach a module not previously in the pruned graph, so Go loads more of the graph to resolve it and records the newly-revealed constraints as additional // indirect lines. The constraints were always logically present in the full graph; pruning had simply elided them. The multi-line diff is pruning working as designed. Reviewers should understand this and not "clean up" the extra lines.
Follow-up. What if a selected version changed too? — The newly-loaded region carried a higher minimum requirement. The recorded result is the correct MVS outcome; if the bump is unwanted, pin a lower compatible version explicitly.
Q12. How do pruning and go mod tidy differ — they both "prune"?¶
Model answer. They are different operations. Module graph pruning (governed by the go directive) trims what the go command loads — the deep, never-imported parts of the graph. go mod tidy trims requirements you no longer use (and adds missing ones), keeping go.mod accurate. Tidy is also the writer that maintains the indirect block pruning depends on. So they cooperate — tidy records what pruning relies on — but they prune different things.
Follow-up. Which one makes my go.mod bigger? — The pruned regime makes the indirect set bigger; tidy is what writes it.
Senior¶
Q13. How does a single go 1.16 dependency affect a pruned project?¶
Model answer. It partially erodes pruning's benefit for everyone downstream. A go ≤ 1.16 module is not self-contained — it did not record the indirect requirements pruning relies on — so when it is relevant, Go must load its full transitive requirements. That re-inflates the loaded graph in that region, and the effect compounds if the legacy module depends on more legacy modules. You cannot fix it purely locally; your go.mod can record versions, but the graph load still expands wherever the old module sits. The senior response: audit dependency go directives (go list -m -json all | jq 'select(.GoVersion < "1.17")') and upgrade or replace the laggards with go 1.17+ releases.
Follow-up. How do you detect this proactively? — Track go mod graph | wc -l; a sudden jump usually traces to a legacy dependency entering the tree.
Q14. Walk through migrating a library from go 1.16 to go 1.21 safely.¶
Model answer. 1. Decide the support floor — the oldest Go your consumers run. 2. Tidy with the new directive and that floor: go mod tidy -go=1.21 -compat=<floor> (e.g., -compat=1.17). 3. Build and test. Verify no inconsistency is reported (-compat checks the two regimes agree). 4. Isolate the large go.mod/go.sum diff in its own commit. 5. Add a CI matrix job on the oldest supported Go that resolves and builds the module as a consumer would — to catch -compat regressions. 6. If you publish, treat raising the floor later as a (minor) breaking change and announce it.
The key risks: tidying away go.sum go.mod hashes an older consumer needs (mitigated by -compat), and bundling the migration with feature work (mitigated by an isolated commit).
Follow-up. What error do older consumers see if you under-set -compat? — "missing go.sum entry" when they load the full graph.
Q15. Why is MVS unchanged by pruning, and where could that break?¶
Model answer. Pruning is engineered as a graph-compression that preserves the MVS fixpoint: for every imported package, MVS over the pruned graph selects the same version as over the full graph. It holds because the pruned graph retains every module supplying an imported package plus its immediate requirements, and the main module's go.mod records the indirect requirements the pruned-away graph would have contributed. It could break if a pruned-away module carried a higher minimum for an imported package and that constraint were not recorded — which is exactly what go mod tidy (and -compat's consistency check) exist to prevent. Any genuine selected-version divergence between pruned and full builds is a bug to report, not expected behaviour.
Follow-up. So what is deepening? — The controlled re-expansion that loads enough graph to record constraints pruning elided; it preserves correctness.
Q16. How does pruning interact with vendoring?¶
Model answer. They are independent but cooperate. Go 1.17 added ## go <version> markers to vendor/modules.txt, recording each module's own go directive. A vendored build does not load the module graph at all — it trusts modules.txt — so it needs these markers to apply correct per-module pruning and language semantics. Vendoring copies the package set the import graph reaches (unchanged by pruning), but the metadata reflects the pruned model. The gotcha: after bumping the main module's go directive or changing dependencies, re-run go mod vendor so the markers and package set match; a stale vendor/ produces "inconsistent vendoring."
Follow-up. Does pruning change which packages get vendored? — No; vendoring is import-driven. It changes the recorded metadata, not the copied package set.
Q17. What is the rule about editing the indirect block and go.sum, and why?¶
Model answer. Don't. Both are derived state owned by go mod tidy. The indirect block records the requirements that make the pruned graph self-contained; deleting a line can break self-containment or change a selected version. go.sum records integrity hashes; deleting a "go.mod h1:" line can break older-Go graph loading. If you want a smaller indirect block, the only correct lever is to actually drop an import and let tidy remove the now-unused requirement. To understand why an entry exists, use go mod why -m <module>, not manual deletion.
Follow-up. What surfaces a hand-edit gone wrong? — go mod tidy reverting your edit, an older consumer's "missing go.sum entry," or a -mod=readonly build erroring with "updates to go.mod needed."
Q18. You bumped the go directive and now two developers' go.mod diffs fight each other. Why?¶
Model answer. Toolchain drift across the pruning boundary. Different local Go versions can produce slightly different indirect sets or go.sum contents — and the default -compat is dynamic (one minor below the directive), so even the retained go.sum entries can differ. Their go mod tidy runs then alternately revert each other. The fix: pin the toolchain (toolchain go1.x.y in go.mod, Go 1.21+), agree on an explicit -compat, document both in CONTRIBUTING.md, and add a CI tidiness gate so only one canonical result is accepted.
Follow-up. Why does the -mod=readonly default matter here? — It surfaces "updates to go.mod needed" when someone forgot to tidy; GOFLAGS=-mod=mod would silently rewrite go.mod and hide the drift.
Staff / Architect¶
Q19. Design a CI strategy for a pruned multi-module monorepo.¶
Model answer. Per-module gates plus repo-wide invariants.
Per module: - Tidiness gate: go mod tidy && git diff --exit-code go.mod go.sum. Catches un-tidied dependency changes — the core pruning invariant. - Build/test with the default -mod=readonly (never GOFLAGS=-mod=mod, which hides tidiness problems).
Repo-wide: - Standardize the go directive across modules; flag any go ≤ 1.16 module (mixed regimes confuse contributors and inflate graphs). - Legacy-dependency audit: fail if any module's dependency graph contains a go < 1.17 module above a threshold, since each erodes pruning downstream. - Toolchain pin (toolchain directive + fixed CI image) to eliminate indirect-block churn. - Graph-size guard: track go mod graph | wc -l per module; alert on jumps.
For published libraries among the modules: an oldest-supported-Go matrix job to catch -compat regressions.
Follow-up. How do you bump a shared dependency across 12 modules? — A scripted bulk change: bump in each go.mod, go mod tidy per module, isolate the dependency diff from code, one PR or a coordinated set.
Q20. What is the performance model of pruning, and how do you measure it?¶
Model answer. Pruning changes the dominant term in graph-load cost from O(full transitive closure) to O(import-relevant subgraph + one level of requirements). The wins land on the common path — go build/go test/go list -m all on a tidy module — which often resolves from the main module's go.mod (lazy loading) or the pruned graph. The expensive commands remain go mod tidy and go mod graph, which are O(full graph) by necessity.
Measurement: - go mod graph | wc -l at the current directive vs a temporary go 1.16 quantifies the reduction. - Time cold go list -m all to capture build-list determination cost. - In CI, watch cold go command wall-clock and go.mod-fetch counts behind a restricted network.
A "pruning optimization" that does not move these numbers is not one.
Follow-up. What single dependency property most affects your graph-load cost? — Dependencies' go directives: a go < 1.17 dep forces full-subtree loading in its region.
Q21. How do you keep go.mod diffs reviewable when pruning makes them noisy?¶
Model answer. Several coordinated practices.
- Separate dependency-bump commits from feature commits. The directive/indirect/
go.sumchurn lives in its own commit; bisecting and review both improve. - Review the direct block carefully, scan the indirect block for surprises (a new heavyweight module, a license-incompatible dependency) rather than line by line — the indirect block is derived.
- Educate reviewers that deepening produces legitimate multi-line
go.moddiffs from one-line import changes; nobody should revert those lines. go mod why -m <module>on any alarming indirect entry.- Gate tidiness in CI so the block is never stale in
main. - Validate the contents, not just the diff: run
govulncheckand license checks so a skimmed indirect block does not let an undesirable dependency in.
The deeper truth mirrors vendoring: if reviewers rubber-stamp the indirect block, an unwanted dependency can enter unnoticed. You manage the noise with tooling; you do not eliminate it.
Follow-up. Should the indirect block be in CODEOWNERS for a security reviewer? — Reasonable for high-assurance repos: route go.mod/go.sum changes to a platform/security path so dependency additions get heightened review.
Q22. Could you implement pruning yourself in tooling, and why is it hard?¶
Model answer. You could approximate it, but you should not re-implement it. The work involves: parsing go.mod (direct + indirect, replace, exclude); computing the import graph to determine import-relevance; gating requirement expansion on each module's go directive (one level for go 1.17+, full subtree for go ≤ 1.16); running MVS over the result; and, for tidy, reconciling against the -compat regime and go.sum. Each piece is non-trivial and drifts between Go releases. The supported approach is to shell out to go list -m -json all, go mod graph, and go mod why, and parse their output — not to re-derive the pruned graph. Use golang.org/x/mod/modfile to read go.mod, never to compute the graph.
Follow-up. Which go list query is most useful for pruning analysis? — go list -m -json all | jq 'select(.GoVersion < "1.17")' to find the non-self-contained dependencies that inflate the graph.
Quick-fire¶
| Q | Crisp answer |
|---|---|
| What turns pruning on? | go directive ≥ 1.17 in the main module. |
| Does pruning change selected versions? | No (preserves MVS). |
Why did go.mod grow? | Pruned go.mod must be self-contained → more // indirect. |
| Are deps' test deps pruned? | Yes (excluded from the pruned graph). |
| Companion feature? | Lazy module loading. |
| Migrate command? | go mod tidy -go=1.17 (or higher). |
What does -compat keep? | go.sum entries an older Go needs + a consistency check. |
| Hand-edit the indirect block? | No — go mod tidy owns it. |
| Which commands load the full graph? | go mod graph, go mod tidy, deep go get, legacy-dep builds. |
vendor/modules.txt 1.17 addition? | ## go <version> per-module marker. |
Mock Interview Pacing¶
A 30-minute interview on module graph pruning might cover:
- 0–5 min: warm-up — Q1, Q2, Q4.
- 5–15 min: middle topics — Q6, Q7, Q11, Q12.
- 15–25 min: a senior scenario — Q13, Q14, or Q15.
- 25–30 min: a curveball — Q19 or Q21.
If the candidate claims modern-Go experience, drive straight to Q2 (why go.mod grew) and Q11 (deepening) — both separate people who read the release notes from people who have migrated a real module. If they only know the headline, stay in middle territory and probe whether they understand the import-vs-require distinction (Q6) and that MVS is unchanged (Q4/Q15). A staff candidate should reach Q19 or Q20 within fifteen minutes and connect pruning to graph-load performance and legacy-dependency hygiene.
In this topic
- specification
- interview
- tasks
- find-bug
- optimize