Tool Dependencies

Updated

September 13, 2026

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.0

go.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 tool

Output:

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 mainstringer 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=Status

That 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/print

Output:

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/print

Update 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 tool

Pin one tool to a newer version:

go get -tool golang.org/x/tools/cmd/stringer@v0.34.0

Remove it entirely:

go get -tool golang.org/x/tools/cmd/stringer@none
go mod tidy

After @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=Status

Two 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. Commit go.mod and go.sum.
  • Run them with go tool or //go:generate go tool ..., never with an unpinned global binary.
  • Use go get tool to bump the whole tool set on purpose.
  • Use @none to remove a tool; then go mod tidy.
  • Reach for a separate go.tool.mod only when tool requires pollute the app graph.
  • Do not keep a tools.go blank-import file in a Go 1.27 module.

Try this

  1. In Case 1’s module, run go list -m -f '{{.Path}} {{.Version}}' all | head and find golang.org/x/tools.
  2. Add StatusHeld to the const block, re-run go tool stringer -type=Status, and confirm status_string.go grew a new name.
  3. Run go get -tool golang.org/x/tools/cmd/stringer@none, then go tool stringer -type=Status. Read the error. Add the tool back.
  4. Replace a Makefile line that says go run golang.org/x/tools/cmd/stringer@... with go tool stringer and commit the go.mod that makes that safe.