Build and toolchain: what vet and gofmt catch¶
Level: 201 · anyone who runs go vet in CI and wonders what it reads
One line: Four vet analyzers read lines the compiler treats as comments — buildtag, directive, stdversion and tests — and gofmt rewrites two of those lines; the list of what none of them catch is the longer one, and this page ends with it.
buildtag: a misplaced or mismatched //go:build line¶
Verified output of build_vet_buildtag_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ go vet . # //go:build after the package clause
# example
# [example]
./pricing_fast.go:3:1: misplaced //go:build comment
exit status 1
$ go vet . # //go:build inside a block comment
# example
# [example]
./pricing_fast.go:2:1: misplaced //go:build comment
exit status 1
$ go vet . # the old +build line disagrees
# example
# [example]
./pricing_fast.go:2:1: +build lines do not match //go:build condition
exit status 1
$ go vet . # a space after //
exit status 0
$ go list -f '{{.GoFiles}}' .
[main.go pricing_fast.go]
$ go vet . # the good version
exit status 0
$ go list -f '{{.GoFiles}}' .
[main.go]
The warning means — the //go:build line is somewhere the go command will not read it (after the package clause, or inside a /* */ comment), or an old // +build line beneath it says something different, so tools that read one line disagree with tools that read the other (buildtag ↗).
The good version — the last two commands: the line first, a blank line after it, and go list shows the file ignored without the tag:
When the tool is right, and when it is noise. Right every time it speaks. The case in the middle is the one to notice: // go:build fast, with a space, drew no report, and go list shows pricing_fast.go compiled into the untagged build — to vet it is an ordinary comment, so there is nothing to check. The analyzer's source has a message for that spelling, but the line never reaches it, because the check is only run on comments containing //go:build.
directive: //go:debug where it is not honoured¶
Verified output of build_vet_directive_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ go vet . # //go:debug after the package clause
# example
# [example]
./main.go:3:1: //go:debug directive only valid before package declaration
exit status 1
$ go build -o report .
exit status 0
$ go vet ./orders # //go:debug in a library package
# example/orders
orders/orders.go:1:1: //go:debug directive only valid in package main or test
exit status 1
$ go vet . # the good version
exit status 0
The warning means — a //go:debug setting is read only from package main (or a test), and only before the package clause (directive ↗, GODEBUG settings ↗). Anywhere else the compiler accepts it as a comment — the go build in the transcript exits 0 — and the setting is never applied.
The good version — the last command:
When the tool is right, and when it is noise. Right: a //go:debug in a library package is the classic mistake, since the setting belongs to the program, not the package, and the godebug directive in go.mod is the way to set it for a module. Noise: never, in this chapter's measurements.
stdversion: an API newer than the go line¶
Verified output of build_vet_stdversion_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ cat go.mod
module example
go 1.21
$ go vet . # slices.Concat is new in Go 1.22
# example
# [example]
./main.go:9:18: slices.Concat requires go1.22 or later (module is go1.21)
exit status 1
$ go build -o report . && ./report
[invoice 1042 3 lines]
exit status 0
$ cat go.mod
module example
go 1.20
$ go vet . # package slices is new in Go 1.21, but the go line is before 1.21
exit status 0
$ go build -o report . && ./report
[10 20 30]
exit status 0
$ go build -o report . # range over an int is Go 1.22 language, and the go line is 1.20
# example
./main.go:6:17: cannot range over 3 (untyped int constant): requires go1.22 or later (-lang was set to go1.20; check go.mod)
exit status 1
$ cat go.mod
module example
go 1.22
$ go vet . && go build -o report . && ./report
line 0
line 1
line 2
exit status 0
The warning means — the module's go line says which Go the code promises to build with, and slices.Concat did not exist in that Go (stdversion ↗). The build succeeded anyway: the compiler enforces the language version — the range 3 under go 1.20 is refused with requires go1.22 or later — but not the standard library's API, because the toolchain in use has the function.
The good version — the last command, with go 1.22 in go.mod; or the older call the promised Go had.
When the tool is right, and when it is noise. Right whenever the go line is a promise to someone — a library's users on an older Go build it, and the failure is theirs. The silence in the middle is the case to know: with go 1.20 and slices.Sort, vet said nothing and the build passed, because the analyzer does not report for modules before Go 1.21, when the go line became a minimum rather than a hint (Go 1.21 release notes ↗). A module whose go line predates 1.21 is checked by nobody.
tests: malformed Test and Example names¶
Verified output of build_vet_tests_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ go vet ./orders
# example/orders
# [example/orders]
orders/orders_test.go:5:6: Testcount has malformed name: first letter after 'Test' must not be lowercase
orders/orders_test.go:7:1: ExampleNoSuch refers to unknown identifier: NoSuch
orders/orders_test.go:9:1: ExampleInvoice_Pay refers to unknown field or method: Invoice.Pay
orders/orders_test.go:11:1: ExampleCount should be niladic
exit status 1
$ go test ./orders
# example/orders
# [example/orders]
orders/orders_test.go:5:6: Testcount has malformed name: first letter after 'Test' must not be lowercase
orders/orders_test.go:7:1: ExampleNoSuch refers to unknown identifier: NoSuch
orders/orders_test.go:9:1: ExampleInvoice_Pay refers to unknown field or method: Invoice.Pay
orders/orders_test.go:11:1: ExampleCount should be niladic
FAIL example/orders [build failed]
FAIL
exit status 1
$ go vet ./orders && go test ./orders
ok example/orders
exit status 0
The warning means — Testcount is not a test, because the letter after Test is lowercase, so go test would run nothing and report ok; ExampleNoSuch documents an identifier the package does not have; ExampleInvoice_Pay names a method Invoice does not have; an example takes no parameters (tests ↗). go test runs this analyzer as part of its own vet pass, so the package fails with [build failed] before any test runs.
The good version — the last command: TestCount, examples named for Count and Invoice, and // Output: lines that go test checks (An example test checks its output).
When the tool is right, and when it is noise. Right every time: a misnamed test is a test that never runs. A wrong signature — func TestCount(t testing.T) — is caught earlier still, by the go command while loading the test package, with wrong signature for TestCount, must be: func TestCount(t *testing.T).
gofmt: the blank line, and the //go:build line it adds¶
Verified output of build_vet_gofmt_buildtag_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ gofmt -d pricing_fast.go # no blank line after //go:build
diff pricing_fast.go.orig pricing_fast.go
--- pricing_fast.go.orig
+++ pricing_fast.go
@@ -1,4 +1,5 @@
//go:build fast
+
package main
func pricing() string { return "fast" }
$ go list -f '{{.GoFiles}} ignored={{.IgnoredGoFiles}}' .
[main.go] ignored=[pricing_fast.go]
$ gofmt -d pricing_fast.go # only the old +build line
diff pricing_fast.go.orig pricing_fast.go
--- pricing_fast.go.orig
+++ pricing_fast.go
@@ -1,3 +1,4 @@
+//go:build fast
// +build fast
package main
$ go vet .
exit status 0
$ go list -f '{{.GoFiles}} ignored={{.IgnoredGoFiles}}' .
[main.go] ignored=[pricing_fast.go]
$ gofmt -w pricing_fast.go && gofmt -l . && head -3 pricing_fast.go
//go:build fast
// +build fast
The warning means — there is none; gofmt -d shows what gofmt -w would change. It inserts the blank line after //go:build that separates the constraint from the package documentation, and above a // +build line with no //go:build it writes the modern line with the same meaning, as it has since Go 1.17 (Go 1.17 release notes ↗).
The good version — the file gofmt -w leaves: both lines, then a blank line. go list showed the constraint honoured before and after, so nothing changed but the text.
When the tool is right, and when it is noise. Right, and cheap: CI that fails on gofmt -l output catches both cases for free. Nothing about a // +build-only file is wrong in Go 1.25 — the constraint holds and vet is silent — but the line is the old syntax, and tools written after 1.17 read only the new one.
What no tool catches¶
// go:build fastwith a space — an ordinary comment; the file is in every build, and thebuildtagtranscript above shows vet silent andgo listcompiling it. Only a reader notices, or a test that fails when the wrong file is compiled in (//go:buildchooses which files are compiled).- A
goline older than 1.21 ingo.modwhile the code uses a newer API —stdversionis silent, the build passes, and the first user on an older Go finds out (thestdversiontranscript above). - A stale generated file — hand-edited, or not regenerated after its input changed. It compiles, vet is quiet, and only
go generate ./...followed bygit diff --exit-codein CI tells (go generateruns a command you name). - An embedded file that is edited after the build — the binary carries the old bytes; nothing knows the edit was meant to be seen (
//go:embedputs files in the binary). -Xon a misspelt name or on a variable with a computed initialiser — exit status 0 both times, and the program saysdev(-ldflags -Xsets a variable at link time).- A release built without
-trimpath— every binary of the release names the directory it was built in and differs from a rebuild elsewhere;go version -mshows no-trimpath=trueline, but nothing looks unless a script does (Builds are reproducible with-trimpath).
staticcheck ↗ and golangci-lint ↗ read the // Code generated … DO NOT EDIT. header and skip such files, and add checks of their own; neither runs in this library.