I still remember the first time I saw a tools.go file. It was in a project at Wawandco, nested under internal/tools/tools.go, and it looked like this:


//go:build tools

package tools

import (
    _ "github.com/golang/mock/mockgen"
    _ "golang.org/x/tools/cmd/stringer"
    _ "github.com/sqlc-dev/sqlc/cmd/sqlc"
)

I stared at it for a solid minute. Blank imports? A build tag that never gets used? The comment at the top that said “//go:build tools” as if that explained anything? I asked a teammate what it was for. He shrugged and said, “That’s how you make sure everyone has the same version of the code generators.”

It was a hack. A useful, necessary, universally-adopted hack. And it annoyed me every time I saw it.

The go tool directive changes how we interact with these tools on the command line. At Leapkit, where we maintain a Go framework, this pattern is essential. Our tools work like this:


go tool db migrate
go tool dev
go tool assets build

These aren’t global binaries scattered across developer machines. They’re pinned in go.mod, cached in the module store, and invoked consistently by everyone on the team. Whether you’re running database migrations, starting the dev server, or building assets, the command is the same across every developer’s machine and CI runner.

The Problem We All Accepted

The issue was simple: Go modules track dependencies for building your application, but they had no concept of tools—the code generators, linters, and build assistants that your project needs to develop, but not to run.

If you wanted to ensure that every developer on your team used the same version of stringer or mockgen, you had two bad options:

Option 1: Ask everyone to install specific versions globally and hope they actually do it. (They won’t.)

Option 2: The tools.go pattern—create a file with blank imports and a tools build constraint so Go’s module system would see the dependency and record it in go.mod, but your actual application wouldn’t import the tool’s massive dependency tree.

We all went with Option 2. It worked. It was also semantically absurd. We’re using a build constraint to prevent building, blank imports to satisfy a module resolver, and a fake package to hold dependencies that aren’t really dependencies. The cognitive overhead was low once you learned it, but the aesthetic overhead never went away. It felt like using a screwdriver as a hammer.

Go 1.24: The tool Directive

Go 1.24, released in February 2025, finally solves this properly. The module system now understands the difference between dependencies you need to build and tools you need to develop.

Instead of the tools.go dance, you add a tool directive to your go.mod:


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

That’s it. Run that, and your go.mod gets a new section:


tool (
    golang.org/x/tools/cmd/stringer v0.30.0
)

The tool gets recorded in go.mod with the exact version, downloaded to the module cache, and available via go tool:


go tool stringer -type=Status ./...

No global installation. No version mismatches between teammates. No blank imports pretending to be architecture.

What I Found Converting Our Repos

I spent a few hours last week migrating our active repositories at Symbol and Wawandco. The process was almost insultingly simple.

First, I deleted internal/tools/tools.go. Good riddance.

Then I ran go get -tool for each code generator we use:


go get -tool github.com/golang/mock/mockgen@latest
go get -tool github.com/sqlc-dev/sqlc/cmd/sqlc@latest
go get -tool github.com/gobuffalo/tags/v3/soda@latest

Each one added the appropriate line to go.mod. The dependencies moved from require blocks (where they polluted our actual build graph) to tool blocks (where they belong). The go.sum file still records the hashes for verification, but our application’s binary no longer carries the transitive weight of mockgen’s parser or sqlc’s query analyzer.

The real win came in our Makefile. Where we used to have:


.PHONY: generate
generate:
    @go run github.com/golang/mock/mockgen@latest -source=...

We now have:


.PHONY: generate
generate:
    @go tool mockgen -source=...

The difference is subtle but meaningful. go run pkg@version always fetches and executes that specific version, ignoring what the project might want. go tool uses the version pinned in go.mod. If someone updates the tool, you see it in code review. If a security issue requires a specific version, it lives in version control where it should be.

The CI/CD Angle

Our CI pipelines got simpler too. We used to have steps that manually installed mockgen or stringer at specific versions, or worse, we used go install without version constraints and prayed the latest release didn’t break us.

Now our GitHub Actions workflows just run go tool commands directly. The tools are already in the module cache from the go mod download step. No extra installation, no version drift between local development and CI, no “works on my machine” when someone has a newer stringer than the build server.

At Symbol, where we run multiple security scanners and code generators across a microservice fleet, this consistency isn’t just convenient—it’s a risk reduction. When everyone’s using the same sqlc version, we don’t get surprised by a query generation change that passed local tests but fails in production builds.

Not Everything Is Perfect

I’ll be honest about the rough edges. The go tool command is straightforward, but the migration path has a few bumps.

Some older tools don’t play nice with being run outside of GOPATH. A few projects we use had hardcoded assumptions about where they’d be installed, which broke when executed from the module cache. Most of these were fixable by updating to newer releases, but one required us to fork and patch. That’s the cost of living on the edge.

There’s also the question of linters. We use golangci-lint, which is technically a Go tool, but it’s complex enough that the go tool directive feels undersized for it. The official recommendation is still to use their install script in CI, or pin the binary. I tried go get -tool for it, and it worked, but the binary size made go mod download noticeably slower. For now, we’re keeping golangci-lint separate, and I’m okay with that. Not every problem needs the same solution.

Why This Matters

In an industry obsessed with the next shiny framework, I bet on stability. The tools.go pattern was stable—it worked for years and will continue working. But it was always a workaround for a missing feature. Go 1.24 doesn’t introduce hype or paradigm shifts. It just fills a gap that every working Go developer felt every day.

The best part? It’s backward compatible. Projects using tools.go don’t break. You can migrate at your own pace, or not at all. That’s the Go team’s philosophy in miniature: evolve carefully, don’t strand users, and solve real problems.

I think about the new developers joining our teams. They’ll never know the tools.go hack. They’ll never have that moment of staring at blank imports wondering if it’s a joke. They’ll just run go get -tool, check in the go.mod, and move on to actual work. That’s the kind of invisible infrastructure I want to build on.

Migration Checklist

If you want to clean up your own repositories:

  1. Find your tools – Search for files with //go:build tools or go:build tools constraints
  2. Identify what’s actually used – Check your Makefile, go:generate directives, and CI scripts
  3. Run go get -tool for each one – Use the version you actually want pinned
  4. Update your scripts – Replace go run pkg@version or global installs with go tool
  5. Delete tools.go – Enjoy the deletion. It’s therapeutic.
  6. Verify in CI – Make sure your pipelines pick up the tools from the module cache

The whole process took me under an hour per repository, and most of that was verifying CI behavior. The actual changes took minutes.

Closing Thoughts

I ran go fix -diff ./... after the migration, just to see if there were any patterns to modernize. There weren’t—this change is too new for the modernizers yet. But the consistency felt good. Clean go.mod, no fake packages, no semantic workarounds.

The go tool directive won’t make headlines. It won’t get a thousand upvotes on Hacker News. It’s just a better way to solve a problem every Go team had already solved poorly. That’s exactly the kind of improvement I want from the language I build businesses on. Quiet, practical, and built to last.

There’s also an engineering angle here that goes beyond convenience. When a team standardizes on go tool, they’re unifying their workflow under the Go ecosystem. Everyone uses the same commands to execute tasks—whether those are external tools like stringer or internal ones like our Leapkit generators. This simplifies the workstream and facilitates quality. Communication overhead drops because the team shares the same patterns. You don’t waste time debugging why Alice’s mockgen output differs from Bob’s when both are running go tool mockgen pinned to the same version in the same go.mod.

Delete your tools.go. You won’t miss it.