Sunday, October 25, 2026
Why Does Go Mod Tidy Change Things I Did Not Expect?
Posted by

Introduction
go mod tidy changes things you did not expect because it does not preserve your hand edits to go.mod. It rewrites go.mod and go.sum to match the packages your source code actually imports, adding missing requirements, removing unused ones, and adjusting versions and // indirect markers to fit that import set. Once you read a tidy diff as a report on your imports, rather than as a random edit, every change in it has a cause you can trace.
Before You Start
- Module: a collection of packages released, versioned, and distributed together, with a
go.modfile at its root. A module path is the module name, and it is the prefix for the import paths of its packages. - Main module: the module containing the directory where the
gocommand is run. Tidy works on this module, and on its packages, not on the whole repository. - Package: a collection of source files in one directory, compiled together. Tidy reasons about packages, then writes module level requirements for the modules that provide them.
- go.mod: the file that declares the module path, the
godirective, and therequire,replace, andexcludedirectives for the module. Arequiredirective states a minimum version of a dependency. - go.sum: the file that records checksums for module files and source archives, used to verify the integrity of downloaded modules. Tidy adds missing entries and removes entries that are no longer needed.
- Direct requirement: a module that provides a package imported by a package in the main module, including a test in the main module. It appears in
go.modwithout an// indirectcomment. - Indirect requirement: a module that does not provide a package imported by a package in the main module, but is needed to build a package the main module does import, or to load the module graph. It appears in
go.modwith an// indirectcomment. - Build list: the list of module versions selected for a build, produced by minimal version selection. It is computed at the start of a module aware command, and it is not stored in a lock file.
- Minimal version selection (MVS): the algorithm that selects, for each module, the highest minimum version required anywhere in the module graph. A new release of a dependency changes no build on its own, because nothing in the graph requires that new version yet.
- Module graph: the directed graph of modules and their requirements, built from
go.modfiles. Tidy loads the graph to find which modules provide the imported packages and which versions MVS selects. - Module graph pruning: a change in Go 1.17 that reduces the module graph for modules at
go 1.17or higher. For a dependency atgo 1.17or higher, the graph includes only that dependency's immediate requirements, not their full transitive requirements, because ago 1.17go.modfile already lists every module needed to build its packages. - Build tag: a constraint in a source file that limits when that file is included in a build, for example to one operating system. Tidy acts as if all build tags are enabled, with one exception, so a platform specific import still counts.
Prerequisites
- Go 1.23 or later, because module graph pruning arrived in Go 1.17, this article uses the pruned graph rules throughout, and the
-diffflag used in this article arrived in Go 1.23. - A terminal, and a text editor.
- No third party packages are required to follow the argument, because the complete example uses illustrative module paths in the style of Go documentation, and the point is the shape of the diff, not the contents of those modules.
go version
Requires: Go 1.23 or later. Tidy, vet, test, and run were checked locally on Go 1.22.2; the -diff step needs Go 1.23 or later. Verified on 2026-10-25 against the official sources in References.
Project Layout
tidy-demo/
├── go.mod # module declaration and requirements, before and after tidy
├── main.go # program code, importing one direct dependency
└── main_test.go # test code, importing a second direct dependency
- go.mod: the file tidy rewrites, shown before and after in the complete example.
- main.go: the program code, whose imports decide which direct requirements survive.
- main_test.go: the test code, whose imports also count, because tidy loads tests of the main module.
What Tidy Actually Does
Tidy has one job, stated in the Go Modules Reference: ensure that go.mod matches the source code in the module. It adds any missing module requirements necessary to build the current module's packages and dependencies, removes requirements on modules that do not provide any relevant packages, adds missing entries to go.sum, and removes unnecessary entries.
It works in two stages. First, it loads a set of packages: all packages in the main module, all packages they import, recursively, including packages imported by tests of the main module. It acts as if all build tags are enabled, so platform specific files count, except for the ignore build tag, which is not enabled. It does not consider packages in directories named testdata, or in directories whose names start with . or _, unless those packages are explicitly imported by other packages.
Second, it rewrites go.mod to fit that loaded set. Every module that provides one or more packages in the set gets a require directive in the main module's go.mod file, at go 1.17 or higher. Every module that provides no package in the set loses its require directive. Tidy may also add or remove // indirect comments, as the direct or indirect status of a module changes.
Two consequences follow from that two stage design, and they explain most surprises. A change to an import, a test, a build tag, or a file location changes the loaded set, and therefore changes the diff, even if you touched nothing in go.mod itself. A change to go.mod alone, with no change to the loaded set, is overwritten by tidy, because tidy derives go.mod from the loaded set, not the other way round.
Why Tidy Adds A Module You Did Not Import
An added module is usually an indirect requirement, and the most common reason is a transitive import: your code imports a package from module A, and a package in module A imports a package from module B. Your code never mentions module B, but the build needs module B, so tidy records it, with an // indirect comment, because module B provides no package imported by a package in the main module.
A second reason is a module a dependency needs for its own tests. At go 1.17 or higher, such a module can stay in the build list, visible in go list -m all, even though it gains no require directive in your go.mod unless a package or test in your own module imports a package from it. That module is still indirect for your module, because no package in your module imports it.
A third reason is the pruned graph itself. At go 1.17 or higher, the main module's go.mod file includes an explicit require directive for each module that provides any package transitively imported by a package or test in the main module. Those indirect requirements are recorded in a separate block in go.mod. A long indirect block after tidy is therefore not a sign of bloat in your code. It is the pruned graph format doing its job: listing, in your file, the modules the build needs, so the full graph does not have to be loaded to resolve them.
To trace an added module, ask which package it provides, and which import chain reaches that package. The command for that question is go mod why, which prints a shortest path through the import graph from the main module to a package or module. If go mod why reports that the main module does not need a package, the module is not in the loaded set for that package, and the diff needs a different explanation, such as a build tag or a test import.
Why Tidy Removes A Module You Thought You Needed
A removed module provided no package in the loaded set. The most common reason is the obvious one: the import was removed from the code, but the require directive stayed in go.mod until tidy ran. Tidy removes the stale directive, and the removal is correct, because no package in the module is built.
Other reasons are less obvious, and each one maps to a rule in the loaded set:
- The import lives only in a test that was deleted: tests of the main module count, so deleting the test removes the package from the loaded set, and tidy removes the requirement that existed only for that test.
- The import lives only in a file with the
ignorebuild tag: tidy does not enable that tag, so the file is not considered, and its imports do not count. - The import lives only in a
testdata,., or_directory: tidy does not consider packages in those directories unless they are explicitly imported by other packages, so an import that exists only there does not count. - The import lives only in a package that is itself not imported: tidy loads packages in the main module and packages they import, recursively, so a package that nothing imports contributes nothing to the loaded set, and neither do its imports.
- The module was kept by hand, with no import behind it: a
requiredirective with no importing package is not a dependency, it is a note, and tidy deletes notes.
A removed module is not deleted from your disk, and it is not uninstalled. It is removed from the requirement list, because the build list for the current import set does not need it. If the import returns, the next tidy run adds the requirement back.
Why Versions Change
Tidy does not upgrade modules to the newest release. MVS selects the highest minimum version required anywhere in the module graph, and a published release that nothing requires changes no build. A version in a tidy diff changes for one of a small set of reasons.
First, a missing module gets the latest version. When tidy adds a requirement for a module that was not required before, it adds a requirement on the latest version of that module, under the version query rules. That is the one case where tidy picks a new version, and it happens because there was no version to keep.
Second, a new import chain raises a minimum. If your code now imports a package from a module, or a dependency now requires a higher minimum of a shared module, MVS selects that higher minimum, because it is the minimum version that satisfies all requirements. The version in your go.mod rises to that minimum, and it rises no further than that minimum.
Third, a recorded requirement is lowered or removed, and a shared module falls back. Tidy alone does not lower a version already recorded in your go.mod, because that recorded minimum still counts in MVS, even after the dependency that first imposed the higher minimum is removed or downgraded. A version goes down only when the recorded requirement itself is lowered or removed, for example with go get for a lower version, after which tidy records the lower minimum.
Fourth, the go directive changes, with or without the -go flag. Tidy with -go=version updates the go directive to that version, enabling or disabling module graph pruning and lazy module loading, and adding or removing indirect requirements as needed for that version. Even without -go, the go directive decides which graph rules apply, so a module at go 1.16 or below follows different indirect requirement rules from a module at go 1.17 or higher, and a tidy diff after a go directive change reflects that rule change, not a code change.
Fifth, a replace or exclude directive in the main module changes the graph. A replace changes the contents used for a version, and an exclude removes a version from the candidate set, so MVS can select a different version for the same import set. Those directives are yours, and tidy respects them, but their effect shows up in the diff as a version change.
To check a version change, compare the new version against the requirements that mention that module. go list -m all prints the build list, and go mod graph prints the module requirement graph, one edge per line. The highest minimum among those edges is the selected version, and if no edge explains it, the change came from a missing module rule, a go directive rule, or a replace or exclude rule.
How To Read A Tidy Diff
Read a tidy diff line by line, and classify each line before judging it:
- Added direct requirement: a package in the main module, or a test in the main module, now imports a package from this module. Find the import.
- Added indirect requirement: a package in a dependency now imports a package from this module, or the pruned graph format now lists it explicitly. Find the import chain with
go mod why. - Removed requirement: no package in the loaded set imports a package from this module any more. Find the import that was deleted, or the file that moved to
testdata, an ignored build tag, or an unimported package. - Version raised: a new import chain, or a new dependency requirement, imposed a higher minimum. Find the edge in
go mod graphthat carries that minimum. - Version lowered: the recorded requirement itself was lowered or removed. Finding only a removed dependency edge is not enough, because a higher minimum still recorded in your
go.modholds the version up. // indirectadded or removed: the module's status changed from direct to indirect, or the reverse, because an import in the main module appeared or disappeared. The version may stay the same while the comment changes, and that change alone is not an upgrade or a downgrade.go.sumlines added or removed: checksums follow the requirement list. Ago.sumchange with nogo.modchange is unusual, and ago.modchange with no matchinggo.sumchange is a broken commit, because the next build needs those checksums to verify downloads.
A diff that looks large is often one import change with a long tail: one new direct import brings a chain of indirect requirements, each with a version chosen by MVS, each with a go.sum entry. Count import changes, not diff lines, when judging whether tidy did too much.
What Tidy Is Not
Tidy is not an upgrade tool, and it is not a lock file writer. It does not move existing requirements to newer releases, and it does not record a build list for later use, because MVS recomputes the build list from the graph at the start of every module aware command.
Three neighbouring topics are covered in their own articles, and are not repeated here. Why do Go modules break when a repo moves? covers a module path that no longer resolves to a repository. Why are nested Go modules bad? covers a go.mod file in a subdirectory that splits one tree into two modules. Why is go.work so frustrating? covers a workspace file that changes which modules act as main modules.
Design Decisions And Tradeoffs
| Option | Use when | Cost |
|---|---|---|
| Run tidy after import changes | Imports, tests, or build tags changed, and go.mod must match the code | The diff can be long, because one import can bring a chain of indirect requirements |
Run tidy with -diff first | You want to review the diff before it is written, or check it in continuous integration | Nothing is written, so a second run is needed to apply the diff |
Run tidy with -v | You want to see which modules tidy removed, printed to standard error | The output lists removals only, not the import chain behind each one |
Hand edit go.mod, then run tidy | You need a replace, exclude, or version change that tidy alone cannot express | Tidy overwrites any hand edit that does not match the loaded set, so the edit must survive a tidy run to be real |
Use go get for a version change | You want a specific version, or you want to remove a module with @none | go get edits requirements directly, so a tidy run afterwards is still needed to clean up the tail |
Commit go.mod without go.sum | Never, for a module with dependencies | The next build cannot verify downloads, and fails for the next person who builds |
Pin a version by hand in go.mod | You need a minimum higher than any dependency imposes, and you want MVS to select it | The pin is a minimum, not a lock, and a higher minimum elsewhere still wins |
| Rely on the indirect block as a dependency list | You want to see every module the build needs, in one file | The block is a graph record, not a list of modules your code imports, so reading it as direct dependencies misleads |
A design in this article has a deliberate compromise, stated openly. The complete example uses illustrative module paths, in the style of Go documentation, rather than real published modules with real version numbers, because no version number in the example was verified by a run. The shape of the diff, the direct and indirect split, and the MVS version choice are the teaching points, and they do not depend on which real modules fill those slots. A real project replaces those paths with real modules, and the same rules apply.
Complete Example
The example is a small module with a program import and a test import, plus a stale requirement and a transitive requirement, so one tidy run touches every kind of diff line.
tidy-demo/
├── go.mod
├── main.go
└── main_test.go
Before tidy, go.mod is stale in three ways: it lacks the test dependency, it lacks the transitive dependency, and it keeps a module nothing imports.
// go.mod, before tidy
module example.com/tidydemo
go 1.20
require (
example.com/alpha v1.0.0
example.com/oldlib v1.0.0
)
The program imports one package from the direct dependency.
// main.go
package main
import (
"fmt"
"example.com/alpha"
)
func main() {
fmt.Println(alpha.Greet("tidy"))
}
The test imports a package from a second module. That import counts, because tidy loads tests of the main module.
// main_test.go
package main
import (
"testing"
"example.com/beta"
)
func TestBetaCheck(t *testing.T) {
if !beta.Check("tidy") {
t.Fatal("beta check failed")
}
}
After tidy, the file shows the shape tidy produces for this import set. The stale module is gone, the test module is present as a direct requirement, and the transitive module is present as an indirect requirement. The transitive version is the MVS choice: if the direct dependency requires version 1.0.0 of the transitive module, and the test dependency requires version 1.2.0 of the same module, MVS selects version 1.2.0, because that is the highest minimum in the graph, and it is the minimum that satisfies both requirements.
// go.mod, after tidy
module example.com/tidydemo
go 1.20
require (
example.com/alpha v1.0.0
example.com/beta v1.0.0
)
require example.com/gamma v1.2.0 // indirect
Commands to run it, from the tidy-demo folder, in order:
go mod tidy -diff
go mod tidy
go build ./...
go test ./...
The -diff run prints the necessary changes as a unified diff, without modifying go.mod or go.sum, and exits with a non zero status if the diff is not empty, so it is the safe way to review the diff before applying it. The plain tidy run writes the diff. The build and test runs confirm that the rewritten requirements still build and test the module.
Why this example shows each tidy rule, in order:
example.com/oldlibis removed, because no package in the loaded set imports a package from it.example.com/betais added as a direct requirement, because a test in the main module imports a package from it.example.com/gammais added as an indirect requirement, because a package in a dependency imports a package from it, and no package in the main module imports it directly.example.com/gammais at version 1.2.0, not 1.0.0, because MVS selects the highest minimum in the graph, and the test dependency imposes the higher minimum.go.sumgains entries for the added modules and loses entries for the removed module, because checksums follow the requirement list.
Testing Tidy Changes
Test a tidy change by the build, not by the size of the diff. A tidy diff is correct when the module still builds and tests, and when a second tidy run changes nothing.
go mod tidy -diff
go build ./...
go test ./...
The -diff check is the tidy test itself: if it exits with a non zero status, go.mod or go.sum does not match the source, and a tidy run is still needed. The build check confirms that every package in the loaded set can be built from the rewritten requirements. The test check confirms that test imports, which tidy counts, are still satisfied.
Two further checks trace a surprising line, rather than accepting or reverting the whole diff:
go mod why example.com/gamma
go list -m all
The first command prints a shortest path through the import graph to the module, or reports that the main module does not need it, which separates a genuine transitive requirement from a stale one. The second command prints the build list, which shows the MVS selected version for every module, and a version in the diff that does not match the build list is a sign that the diff was edited by hand after tidy ran.
Common Mistakes
- Hand editing
go.modand expecting tidy to keep the edit: tidy derivesgo.modfrom the loaded set, so a hand added requirement with no importing package is removed on the next run. The fix is to add the import first, or to express the intent with areplace,exclude, or a higher minimum that MVS can select. - Deleting a
go.sumline by hand:go.sumis a checksum record, not a dependency list, and a missing entry fails the next verification. The fix is to let tidy rewritego.sumwithgo.mod, and to commit both files together. - Committing
go.modwithoutgo.sum: the next build cannot verify downloads for the new requirements. The fix is to commit both files in the same change, every time. - Reading an indirect requirement as a direct dependency: an
// indirectcomment means no package in the main module imports that module. The fix is to read the comment, and to usego mod whyfor the import chain, before removing or upgrading the module. - Reading a version change as an upgrade: tidy does not upgrade existing requirements to newer releases. A version change means a new minimum, a removed minimum, a
godirective change, or a replace or exclude change. The fix is to find the edge ingo mod graphthat carries the new minimum. - Removing an import and skipping tidy: the stale requirement stays in
go.mod, and the next reader assumes the module is still used. The fix is to run tidy after import changes, and to check it in continuous integration with-diff. - Running tidy in the wrong directory: tidy works on the main module containing the current directory, so running it in a subdirectory of a different module tidies that module, not the one intended. The fix is to run it from the module root, and to check the module path at the top of the resulting
go.mod. - Treating a long indirect block as bloat to delete: at
go 1.17or higher, the indirect block is the pruned graph record, and deleting it breaks the build list. The fix is to leave the block alone, and to remove the import that brings the chain in, if the chain itself is unwanted. - Using
-eto silence a tidy failure: the-eflag attempts to proceed despite errors loading packages, which can write ago.modbased on a partial loaded set. The fix is to fix the loading error first, and to use-eonly when the partial result is understood and checked. - Editing
go.modin a merge and resolving it by hand: a merge can leave requirements that match neither side's import set. The fix is to resolve the conflict by running tidy on the merged code, rather than by picking lines from either side.
Production Notes
- Continuous integration: run
go mod tidy -diffas a check, so a change that adds or removes an import without tidying fails the build, rather than landing a stalego.modin the main branch. - Review: review a tidy diff by import changes, not by line count. One new import can bring a chain of indirect requirements, and a version change in the chain needs the same review as a direct version change, because MVS selects it for the build.
- Vendoring: if the project vendors dependencies, regenerate the vendor tree after tidy, because
vendor/modules.txtrecords the module versions that were vendored, and a stale vendor tree disagrees with the newgo.mod. - Reproducibility: commit
go.modandgo.sumtogether, and do not hand edit either file after tidy, because MVS recomputes the build list from the graph, and a hand edit that disagrees with the graph is overwritten or fails verification. - Deployment: the example needs a Go 1.23 or later toolchain, because the
go mod tidy -diffcheck in this article arrived in Go 1.23, while the pruned graph rules apply atgo 1.17or higher and thegodirective in the example is 1.20. Keep that directive ingo.mod, rungo mod tidy -diff,go build ./..., andgo test ./...in continuous integration, and do not deploy a change that fails any of those checks.
Decision Guide
- When imports changed, and
go.modmust match the code: rungo mod tidy, review the diff by import changes, then build and test. - When you want to review before writing: run
go mod tidy -difffirst, and apply the diff only after the added, removed, and version changed lines each have an import explanation. - When a module appears that you did not import: run
go mod whyfor that module, and keep it if the import chain is genuine, or remove the import that brings the chain in if it is not. - When a module disappears that you expected to keep: find the import that was deleted, moved to an ignored file, or left in an unimported package, and restore that import, or accept the removal.
- When a version changes and you did not ask for an upgrade: find the new minimum in
go mod graph, or thegodirective, replace, or exclude change behind it, before reverting the version by hand. - When you want a specific version: use
go getfor that version, which edits the requirement directly, then run tidy to clean up the tail, rather than hand editing the version and hoping tidy keeps it.
Conclusion
A tidy diff is not a random rewrite. It is a report on your imports, written in the language of requirements: direct for packages your module imports, indirect for packages your dependencies import, removed for modules nothing imports, and versioned by the highest minimum in the graph. Read it that way, and the surprises become traceable, one import at a time.
Summary Of Key Points
- Tidy matches code, not hand edits:
go.modandgo.sumare rewritten to fit the packages your source code imports, so a hand edit with no import behind it does not survive. - Added modules have an import chain: a direct addition comes from a package or test in the main module, and an indirect addition comes from a dependency or the pruned graph record.
- Removed modules have no import left: the import was deleted, ignored, moved to an unconsidered directory, or left in an unimported package, and the requirement follows the import out.
- Versions follow MVS, not newest releases: the selected version is the highest minimum in the graph, a missing module gets the latest version, and a version falls only when the recorded requirement itself is lowered or removed.
- Pruning shapes the indirect block: at
go 1.17or higher, the main module lists every module that provides a transitively imported package, so a long indirect block is the format working, not bloat. - Read the diff by imports, check it with
-diff: classify each line by its import cause, trace it withgo mod whyandgo list -m all, and let continuous integration fail whengo.moddoes not match the code.
Follow those rules, and a tidy diff stops looking like an edit you did not make, and starts looking like what it is: your import set, written out as requirements.
Related Posts
- Why Do Go Modules Break When A Repo Moves?
- Why Are Nested Go Modules Bad?
- Why Is Go.Work So Frustrating?
Learn more:
Additional Resources
- Go Documentation: Managing dependencies - the workflow for adding, upgrading, and synchronizing dependencies, including the tidy step
- Go Documentation: Go Modules Reference - the full reference for tidy, minimal version selection, module graph pruning, and the go.mod file
- Go Documentation: go.mod file reference - the require, indirect, replace, and exclude directives, and the go directive rules
- Go Packages: Command go - the go command documentation, including module commands
References
- Managing dependencies. Go team, Go Documentation. https://go.dev/doc/modules/managing-dependencies. Accessed 2026-10-25.
- Go Modules Reference. Go team, Go Documentation. https://go.dev/ref/mod. Accessed 2026-10-25.
- go.mod file reference. Go team, Go Documentation. https://go.dev/doc/modules/gomod-ref. Accessed 2026-10-25.
- Command go. Go team, Go Packages. https://pkg.go.dev/cmd/go. Accessed 2026-10-25.