golangci-lint のバージョンがローカルとCIでずれて、手元で通った lint がCIで落ちる。Goのモジュールはアプリの依存を go.sum で厳密に固定する一方、開発ツールの依存はずっと管理の外に置かれていました。Go 1.24で入った tool ディレクティブ が、この穴を塞ぎます。
tools.goパターンは何を我慢していたか
Go 1.24より前は、ツールの依存をモジュールに含める標準の手段がありませんでした。go install golangci-lint@latest のようにグローバルへ入れると、入れた人ごとにバージョンが変わる。これを避けるための定番が tools.go でした。
blank importでツールを依存に紛れ込ませる
ビルド対象に含めたくないので、専用のビルドタグでガードしたファイルにツールのパッケージを空インポートします。
//go:build tools
// +build tools
package tools
import (
_ "golang.org/x/tools/cmd/stringer"
_ "golang.org/x/tools/cmd/goimports"
)
空インポートなので go.mod の require に乗り、go.sum でバージョンが固定される。ツール本体は go run golang.org/x/tools/cmd/stringer とフルパス指定で呼ぶ運用でした。
ダミーパッケージとビルドタグが要る
この方式は動きます。ただし中身が空の package tools をリポジトリに置く必要がある。//go:build tools を書き忘れると、通常ビルドにツールの依存が混ざります。公式ドキュメントもこの回避策を過去のものと位置づけていて、Go 1.24のリリースノートには次のようにあります。
Go modules can now track executable dependencies using tool directives in go.mod. This removes the need for the previous workaround of adding tools as blank imports to a file conventionally named “tools.go”.
go get -tool でgo.modに書き込む
新しいやり方はコマンド1本。go get に -tool を付けると、指定パッケージが tool ディレクティブとして go.mod に書き込まれます。
tool ディレクティブが1行追加される
$ go get -tool golang.org/x/tools/cmd/stringer
go: downloading golang.org/x/sync v0.21.0
go: downloading golang.org/x/mod v0.37.0
go: added golang.org/x/mod v0.37.0
go: added golang.org/x/sync v0.21.0
go: added golang.org/x/tools v0.46.0
go.mod を開くと tool 行が増えています。require には // indirect 付きで実体が並ぶ。
module example.com/tooldemo
go 1.25.0
tool golang.org/x/tools/cmd/stringer
require (
golang.org/x/mod v0.37.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/tools v0.46.0 // indirect
)
2つ目を足すとブロックにまとまる
ツールを追加すると、tool 行は自動でブロック表記に変わります。import文と同じ感覚。
$ go get -tool golang.org/x/tools/cmd/goimports
tool (
golang.org/x/tools/cmd/goimports
golang.org/x/tools/cmd/stringer
)
これでツールのバージョンはアプリの依存と同じ go.sum に乗る。チーム全員が同じ golang.org/x/tools v0.46.0 を使う状態になります。
go tool で実行する
追加したツールは go tool から呼びます。Go同梱の vet や cover と同じ入り口。
パス末尾だけで呼べる
インポートパスの最後の要素を渡すだけで実行できます。goimports で整形差分を出してみます。
$ go tool goimports -d main.go
--- main.go.orig
+++ main.go
@@ -1,6 +1,7 @@
package main
import "fmt"
+
func main() {
m := map[string]int{"b": 2, "a": 1}
fmt.Println(m["a"])
フルパス go tool golang.org/x/tools/cmd/goimports でも動く。末尾名が他のツールと衝突するときはフルパスが要ります(後述)。
go tool 単体で一覧を出す
引数なしの go tool は、使えるツールを全部並べます。上半分がGo同梱、下半分が go.mod で追加したもの。
$ go tool
asm
cgo
compile
cover
fix
link
preprofile
vet
golang.org/x/tools/cmd/goimports
golang.org/x/tools/cmd/stringer
tools.goから移行する
既存プロジェクトの移行は機械的に進みます。tools.go に並んでいたパッケージを、そのまま go get -tool に渡し替えるだけ。
importをgo get -toolに置き換える
tools.go の空インポート1行が、コマンド1本に対応します。
# 旧: tools.go に _ "golang.org/x/tools/cmd/stringer"
# 新:
$ go get -tool golang.org/x/tools/cmd/stringer
$ go get -tool golang.org/x/tools/cmd/goimports
tools.goを消してgo mod tidy
ツールを全部 tool ディレクティブに移したら、tools.go を削除します。残った require の整理は go mod tidy に任せる。
$ rm tools.go
$ go mod tidy
呼び出し側も go run …/stringer から go tool stringer へ短くなる。Makefile や go:generate コメントの該当箇所を置換すれば移行完了です。手で go.mod に tool 行を書いた場合も、require が無ければ go mod tidy が補います。
実行ファイルはビルドキャッシュに乗る
Go 1.24では go tool の実行ファイルがビルドキャッシュに保存されるようになりました。リリースノートの記述。
Executables created bygo runand the new behavior ofgo toolare now cached in the Go build cache. This makes repeated executions faster at the expense of making the cache larger.
初回と2回目で速度が変わる
go clean -cache 直後の初回はツールをビルドするので時間がかかる。2回目以降はキャッシュから起動します。同じ go tool goimports --help を3回叩いた実測。
$ go clean -cache
$ time go tool goimports --help # 1回目: ビルドあり
3.023 total
$ time go tool goimports --help # 2回目: キャッシュヒット
0.196 total
$ time go tool goimports --help # 3回目
0.059 total
初回の3.0秒に対し、2回目は0.2秒、3回目は0.06秒。CIでキャッシュを温めておけば、2回目以降の起動は0.1秒を切ります。
キャッシュが膨らむ代わり
速くなる代償として、ビルドキャッシュのサイズは増えます。リリースノートも “at the expense of making the cache larger” と明記している。CIのキャッシュ容量を切り詰めている環境では、この増加分が効いてきます。
全ツールをまとめて扱うメタパターン
go.mod 内の全ツールは tool という名前でまとめて指せます。個別パスを並べずに一括操作できる。
$ go get -u tool # 全ツールをアップグレード
go: upgraded golang.org/x/telemetry => v0.0.0-20260611...
$ GOBIN=$(pwd)/bin go install tool # 全ツールをGOBINへ
$ ls bin/
goimports stringer
3つの方式を並べると、何が go.mod 側に移ったかが見えます。
| 方式 | バージョン固定 | 追加で要るもの | 実行方法 |
|---|---|---|---|
go install …@latest | されない | なし(GOBIN汚染) | PATH上のバイナリ |
tools.go + blank import | go.sum | ダミーpkg + build tag | go run フルパス |
tool ディレクティブ | go.sum | go.mod の1行 | go tool 末尾名 |
踏みやすい2つの罠
移行時に引っかかる点もあります。どちらも仕組みを知っていれば避けられる。
末尾名が衝突するとフルパスが要る
go tool の短縮呼び出しは、インポートパスの末尾要素を使います。違うモジュールでも末尾が同じだと、どちらを指すか決まらない。たとえば自作の example.com/a/cmd/gen と example.com/b/cmd/gen を両方ツール登録すると、go tool gen は曖昧になります。
# NG: 末尾 gen が衝突して解決できない
$ go tool gen
# OK: フルパスで一意に指す
$ go tool example.com/a/cmd/gen
Go同梱ツールと同じ名前(vet など)を登録したときも、同じ理由でフルパス指定になります。
モジュールグラフに依存が増える
tool ディレクティブは、ツールの依存をあなたのモジュールの require に取り込みます。golangci-lint のような巨大ツールを足すと、go.mod の // indirect が一気に増える。アプリの実行バイナリには含まれませんが、go mod graph は太ります。
これが気になる規模なら、CIでしか使わない重量級ツールだけ tool から外し、go run …@version でバージョン指定実行に留める手もあります。全部を go.mod に押し込むのが常に正解とは限らない。判断の軸は「チーム全員がローカルで同じバージョンを使う必要があるか」です。手元で頻繁に叩く stringer や mockgen は tool 向き。CIの最終チェックだけの govulncheck なら、バージョン固定実行でも足ります。
まとめ
Go 1.24のtoolディレクティブは、tools.goの空インポートという回避策を正式な機能に置き換えました。現行のGo 1.26でも挙動は同じです。
go get -tool パスでgo.modにtool行が追加され、バージョンがgo.sumで固定される- 実行は
go toolに末尾名を渡す。一覧は引数なしのgo tool - 移行は
go get -toolへ置き換えてtools.goを消し、go mod tidyで整える - 実行ファイルはビルドキャッシュに乗り、2回目以降は初回の数十分の1で起動する
- 末尾名の衝突はフルパスで回避。重量級ツールはモジュールグラフとの相談で
tool登録を見送る選択もある

