Tool Dependencies
Tool Dependencies
Libraries belong in require. Generators and linters belong in tool. The boring default since Go 1.24 is to pin those executables in go.mod with go get -tool, then run them with go tool. Same version on your laptop and in CI. No blank-import tools.go. No “works on my machine” go install of whatever was latest last Tuesday.
Mental model
A require line is a module your packages import at build time. A tool line is a package path of a main program your project needs to develop, generate, or check code. The module still records the tool’s module version in require (usually with // indirect). tool is the explicit list of “run this with go tool.”
go get -tool path@version # add or pin a tool
go get -tool path@none # remove it
go get tool # upgrade every tool in this module
go tool name [args...] # build and run the pinned tool
go tool stringer works when the last path segment is unique among tools in the module. Full package paths work too. Built-in tools (go tool compile, go tool nm) still win their names.
If tool modules would bloat the application graph, put them in a separate go.tool.mod and pass -modfile=go.tool.mod. Most desks start with tools in the main go.mod.
Worked examples
Case 1: Pin stringer in go.mod
Empty directory. Save go.mod:
module example.com/desk
go 1.27
Add the tool (needs the network once):
go get -tool golang.org/x/tools/cmd/stringer@v0.34.0go.mod now looks like this (exact indirect versions follow the pin you asked for):
module example.com/desk
go 1.27
tool golang.org/x/tools/cmd/stringer
require (
golang.org/x/mod v0.25.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/tools v0.34.0 // indirect
)
go.sum grows. Commit both files. The tool is not imported by your packages. It is still a dependency of the module graph, recorded on purpose.
List tools:
go list toolOutput:
golang.org/x/tools/cmd/stringer
Case 2: Generate String for a desk status
Keep the go.mod from Case 1. Save as status.go (package desk, not main — stringer loads types from a normal package):
// status.go
package desk
type Status int
const (
StatusOpen Status = iota
StatusClosed
)Run the pinned tool:
go tool stringer -type=StatusThat writes status_string.go. The important parts look like this (generated; do not hand-edit):
// status_string.go
// Code generated by "stringer -type=Status"; DO NOT EDIT.
package desk
import "strconv"
func _() {
var x [1]struct{}
_ = x[StatusOpen-0]
_ = x[StatusClosed-1]
}
const _Status_name = "StatusOpenStatusClosed"
var _Status_index = [...]uint8{0, 10, 22}
func (i Status) String() string {
if i < 0 || i >= Status(len(_Status_index)-1) {
return "Status(" + strconv.FormatInt(int64(i), 10) + ")"
}
return _Status_name[_Status_index[i]:_Status_index[i+1]]
}Save as cmd/print/main.go:
// cmd/print/main.go
package main
import (
"fmt"
"example.com/desk"
)
func main() {
fmt.Println(desk.StatusOpen)
fmt.Println(desk.StatusClosed)
}Run:
go run ./cmd/printOutput:
StatusOpen
StatusClosed
Without String, fmt would print 0 and 1. The tool keeps names and values aligned when you add a constant later and re-run go tool stringer.
Case 3: Wire go generate
Put the generate directive in the file that owns the type. Save as status.go:
// status.go
package desk
//go:generate go tool stringer -type=Status
type Status int
const (
StatusOpen Status = iota
StatusClosed
StatusHeld
)From the module root:
go generate ./...
go run ./cmd/printUpdate cmd/print/main.go to print the new constant too:
// cmd/print/main.go
package main
import (
"fmt"
"example.com/desk"
)
func main() {
fmt.Println(desk.StatusOpen)
fmt.Println(desk.StatusClosed)
fmt.Println(desk.StatusHeld)
}Output:
StatusOpen
StatusClosed
StatusHeld
The directive says go tool stringer, not go run golang.org/x/tools/cmd/stringer@v0.34.0, and not a bare stringer from $PATH. CI runs the same command and gets the same binary.
Case 4: Upgrade and remove
Upgrade every tool listed in this module:
go get toolPin one tool to a newer version:
go get -tool golang.org/x/tools/cmd/stringer@v0.34.0Remove it entirely:
go get -tool golang.org/x/tools/cmd/stringer@none
go mod tidyAfter @none, go list tool prints nothing for that path, and go tool stringer fails until you add it back. That failure is good: the module no longer pretends it owns a generator.
The trap
The old pattern was a file that existed only to pull tools into the module graph:
// tools.go
//go:build tools
package tools
import (
_ "golang.org/x/tools/cmd/stringer"
)Then everyone ran a different global install:
go install golang.org/x/tools/cmd/stringer@latest
stringer -type=StatusTwo problems. The blank import is a lie — you never call that package. And @latest on one laptop is not the version CI used last month. Generated files drift. Reviews turn into “re-run stringer” noise.
The fix is the Case 1 tool block plus go tool stringer (or go generate). Delete tools.go. Put the same go tool / go generate lines in the Makefile and the workflow file.
A related trap: pinning a huge unrelated Go program as a tool “because we can.” Prefer project generators and analyzers. Install one-off CLIs another way if they only waste module download time.
The boring rule
- Pin tools with
go get -tool path@version. Commitgo.modandgo.sum. - Run them with
go toolor//go:generate go tool ..., never with an unpinned global binary. - Use
go get toolto bump the whole tool set on purpose. - Use
@noneto remove a tool; thengo mod tidy. - Reach for a separate
go.tool.modonly when tool requires pollute the app graph. - Do not keep a
tools.goblank-import file in a Go 1.27 module.
Try this
- In Case 1’s module, run
go list -m -f '{{.Path}} {{.Version}}' all | headand findgolang.org/x/tools. - Add
StatusHeldto the const block, re-rungo tool stringer -type=Status, and confirmstatus_string.gogrew a new name. - Run
go get -tool golang.org/x/tools/cmd/stringer@none, thengo tool stringer -type=Status. Read the error. Add the tool back. - Replace a Makefile line that says
go run golang.org/x/tools/cmd/stringer@...withgo tool stringerand commit thego.modthat makes that safe.