Go tooling: build, vet, fmt and what they check
Interview questions on Go tooling climb a ladder: how go run differs from go build — why the second build is instant — why gofmt — what go vet catches if the code compiles — how to build a Windows binary on Linux — how to embed a version into it. A run answers each step: a cold build called the compiler 55 times, a repeated one not once; a Printf with the wrong verb and a copied sync.Mutex built without a single error, and go vet caught both; go test let the copied mutex through, because it runs only part of vet's checks; and -ldflags -X silently ignored a typo in the variable name.
Full technical treatment
TL;DR
go is one command that builds, runs, formats and checks code. go build
compiles packages and links a binary, go run does the same into a temporary
directory and runs the result straight away, gofmt puts code into the one
canonical format, and go vet looks for errors the compiler lets through. All of it
ships with the standard Go distribution — nothing to install separately.
The compiler lets many real errors through, and go test does not catch
all of them. Printf("%d") with a string, a copy of sync.Mutex in a value
receiver, unreachable code, a discarded fmt.Sprintf result — in the run all
of it built without a single error, and go vet caught all four. But go test
runs only part of vet's checks: the test of the package with the copied mutex
passed and printed after Inc: 0 — the counter never went up.
Beyond that comes what separates real understanding from having read about
it. A cold build of a program using fmt and os called the compiler
55 times, a repeated one not once and finished tens of times faster: the
packages sit in the cache, and the cache key is the content of the files, not
their modification time. go run builds without debug information — its
binary matched the size of go build -ldflags "-s -w" to the byte — and turns the program's
exit code 3 into its own 1. An opening brace on its own line is a syntax error,
not a style choice. GOOS=windows go build on Linux produced a PE file without
a single external tool. And -ldflags "-X …" silently passes over a typo in
the variable name: the version stayed dev, exit code 0.
- how to write a program in package
mainand run it; - what a module and the
go.modfile are; - that a method can have a value receiver or a pointer receiver.
- the build cache and the difference between
go runandgo build; - what
go vetcatches, which of its checksgo testruns and whatgolangci-lintadds; - build constraints, cross-compilation, the
-ldflagsflags andgo generate.
What is really being asked
The ladder is almost always this one:
- "How does
go rundiffer fromgo build?" — the warm-up; "run also runs it" is correct but incomplete. - "Why is the second build instant?" — about the build cache.
- "Why
gofmt, and can you argue with it?" — checks whether the candidate understands why Go has a single format. - "The code compiles — why
go veton top?" — the main question: what the compiler lets through and which of it vet catches. - "How do you build a Windows binary on Linux?" — about
GOOS,GOARCHand build constraints. - "How do you embed a version into a binary?" — about
-ldflags.
The ladder has one spine: every tool has its own boundary — what it sees and
what it lets through. The compiler checks types but not format strings; vet
checks format strings but not ignored errors; go test runs vet, but not all
of it; the cache sees the content of files but not their modification time.
What sets a candidate apart is knowing those boundaries.
Base: one command, several subcommands
Everything asked about is a subcommand of one program, go, plus one separate
utility, gofmt, from the same distribution:
| command | what it does | what is left after it |
|---|---|---|
go build | compiles packages and dependencies; for package main links a binary | an executable in the current directory (or at -o) — for package main only |
go run | the same, into a temporary directory, and runs it right away | nothing in the project directory |
go test | builds a test binary, runs part of vet's checks, runs the tests | a result in the test cache |
go vet | static checks for suspicious constructs | messages only |
gofmt -l / go fmt | lists / fixes files whose format differs from the canonical one | go fmt rewrites the files |
go generate | runs commands from //go:generate comments | whatever those commands wrote |
Two things from the table are worth saying at once. First: the go
subcommands work on packages — go build ./... builds all packages of the
module, go vet ./... checks them; a list of .go files from one directory
(as in go run gen.go) is treated as one package. gofmt, a separate
utility, works on files and directories. Second: go fmt is a wrapper around gofmt;
by the documentation it runs gofmt -l -w on the named packages.
A junior-level answer: go build compiles and leaves a binary, go run
compiles into a temporary directory and runs it, go test builds and runs the
tests, gofmt formats the code in the single accepted way, go vet looks for
errors the compiler lets through — for example, a wrong verb in Printf.
Mechanism 1: the build cache — and what go run really does
The program in cases/hello uses only fmt and os. Let us build it three
times: with an empty cache, with a warm cache, and once more when the binary
is already in place. How many times the compiler and the linker were called is
visible from the lines of go build -x:
1) BUILD CACHE: COLD AND REPEATED BUILD
───────────────────────────────────────
go env GOCACHE (default) -> <home>/.cache/go-build
program: cases/hello — fmt and os, nothing else
the distribution's $GOROOT/pkg directory: include tool
go build -o hello ./hello:
cold cache 6.6 s compile: 55, link: 1
warm cache, no hello file yet 0.16 s compile: 0, link: 1
warm cache, hello already built 0.04 s compile: 0, link: 0
cold / warm: 41
Of the fifty-five compiler calls, one is package main itself; the other 54
are the standard library: fmt, os and everything they depend on. The
distribution ships no compiled standard library packages — the $GOROOT/pkg
directory holds only include and tool. Each package is compiled on the
first build and put into the cache, whose directory go env GOCACHE prints.
From then on it is taken from there. On go1.25.1 there were 60 calls — the
standard library changes from version to version.
The third line shows one more thing: if the binary has already been built from
the same inputs, go build does not call even the linker. An identifier of
its inputs (the build ID) is written into the binary itself, and go build
compares it with what would come out now: if they match, the file is left
alone.
What counts as "the same inputs"? The documentation promises:
The build cache correctly accounts for changes to Go source files, compilers,
compiler options, and so on: cleaning the cache explicitly should not be
necessary in typical use.
And the run shows by which sign it does so:
11) THE CACHE KEY IS CONTENT, NOT FILE TIME
───────────────────────────────────────────
repeated build, no changes compile: 0, link: 0
after touch main.go (time +1 h) compile: 0, link: 0
after editing a comment compile: 1, link: 1
touch moved the file's modification time by an hour — the build did not
notice. Editing a comment, which does not affect the code, recompiled package
main and relinked the binary. The cache key is the content of the inputs,
not the time of the files. That is why go clean -cache "just in case" is
almost never needed; the documentation names the exception — the cache does
not see changes to C libraries pulled in through cgo.
Now go run. It is often described as "go build plus running", and the
documentation adds an important detail:
By default, 'go run' compiles the binary without generating the information
used by debuggers, to reduce build time.
2) go run: TEMPORARY DIRECTORY AND EXIT CODE
────────────────────────────────────────────
first go run compile: 1, link: 1
binary: <tmp>/go-build<N>/b001/exe/hello
file still there after exit: false
second go run compile: 0, link: 0
binary: <GOCACHE>/<xx>/<hash>-d/hello
file still there after exit: true
The first go run came right after go build with a warm cache — and still
called the compiler: for it, package main is built with different flags,
without DWARF, and that is a different cache key. The binary landed in the
temporary directory go-build<N>, and after the program exits it is no longer
there. The second go run called neither the compiler nor the linker: it ran
the binary straight from the build cache.
This is visible in the cmd/go source as well: go run sets OmitDebug on
the package, so the compiler gets -dwarf=false and the linker gets -s and
-w; and it sets CacheExecutable, so the finished binary is put into the
cache. The size confirms it (section 9 of the run):
size of cases/hello:
go build 2 224 064 bytes .symtab present, .debug_info present
go build -ldflags "-s -w" 1 446 072 bytes .symtab absent, .debug_info absent
go run binary from cache 1 446 072 bytes .symtab absent, .debug_info absent
The go run binary matched go build -ldflags "-s -w" in size, to the byte. And the
last difference is the exit code:
The exit status of Run is not the exit status of the compiled binary.
go run ./hello fail:
exe: <GOCACHE>/<xx>/<hash>-d/hello
exit status 3
go run exit code: 1
exit code of the program itself: 3
The program called os.Exit(3); go run printed exit status 3 and itself
exited with code 1. For a script that checks the exit code, these are
different programs. Hence the practical rule: go run is for development;
in CI, in a Docker image and anywhere the exit code, debugging or
reproducibility matter — go build and running the binary.
Mechanism 2: gofmt — a format that is not up for discussion
gofmt does one thing: puts a file into the canonical form. The -l flag
prints the names of files whose format differs, -d prints the difference,
-w rewrites the file. The test subject cases/messy/main.go builds without
errors but is written any old way. Below are the diff lines that concern the
struct and main (the full diff, with imports, is in the run record):
3) gofmt -l AND gofmt -d
────────────────────────
gofmt -l messy/ tags/
messy/main.go
go build ./messy/
built, no errors
gofmt -d messy/main.go
-type Config struct{
- Name string
- Port int
- Debug bool
-}
-func main ( ) {
- c:=Config{Name:"api",Port:8080}
- if c.Port>1024 {fmt.Println( strings.ToUpper(c.Name),c.Port )}
+type Config struct {
+ Name string
+ Port int
+ Debug bool
+}
+
+func main() {
+ c := Config{Name: "api", Port: 8080}
+ if c.Port > 1024 {
+ fmt.Println(strings.ToUpper(c.Name), c.Port)
+ }
Everything gofmt decides for the author is visible: indentation is a tab, not
spaces; struct fields are aligned with spaces; spaces around :=, after : in
a literal and around >; blank lines between declarations; the body of if
on its own lines. The tags/ files did not show up in the -l list: they are
already canonical. The documentation states the rule in one sentence:
Gofmt formats Go programs. It uses tabs for indentation and blanks for
alignment.
gofmt has no style settings: by go doc cmd/gofmt its flags are -d, -e,
-l, -r, -s and -w, and none of them sets an indent width or a brace
position. That is the answer to "can you argue with it": you cannot, and
that is the point. Code in any Go project looks the same, review is not
spent on whitespace, and a diff shows only changes of meaning.
There is one thing gofmt will not fix, because it is not style. An opening
brace on its own line:
func main()
{
} gofmt -l brace/ (opening brace on its own line)
brace/main.go:5:1: unexpected semicolon or newline before {
go build ./brace/
brace/main.go:5:1: syntax error: unexpected semicolon or newline before {
It is a syntax error, and gofmt does not format it but reports it. The reason
is in the specification, in the automatic semicolon rule:
When the input is broken into tokens, a semicolon is automatically inserted
into the token stream immediately after a line's final token if that token is
— and further down the list is the closing parenthesis ). The line
func main() ends in ), the lexer puts a semicolon after it, and { on the
next line ends up after a finished declaration. In Go the position of the brace
is decided by the grammar, not by convention.
Mechanism 3: what the compiler lets through and go vet catches
The Go compiler is strict: an unused variable is an error for it, not a warning (the specification allows a compiler this restriction, and gc uses it). Hence the feeling that "if it built, all is well". The vet documentation says otherwise:
Vet uses heuristics that do not guarantee all reports are genuine problems, but
it can find errors not caught by the compilers.
Seven test programs, one error in each. For example, printf and copylock:
name := "gopher"
fmt.Printf("hello, %d\n", name)type Counter struct {
mu sync.Mutex
n int
}
func (c Counter) Inc() {
c.mu.Lock()
c.n++
c.mu.Unlock()
}What the compiler and vet said:
4) THE COMPILER VERSUS go vet
─────────────────────────────
unusedvar:
go build: ./main.go:7:2: declared and not used: count
go vet: vet: ./main.go:7:2: declared and not used: count
printf:
go build: built, no errors
go vet: ./main.go:8:2: fmt.Printf format %d has arg name of wrong type string
copylock:
go build: built, no errors
go vet: ./main.go:15:9: Inc passes lock by value: example.com/toolcases/copylock.Counter contains sync.Mutex
unreachable:
go build: built, no errors
go vet: ./main.go:8:2: unreachable code
unusedresult:
go build: built, no errors
go vet: ./main.go:8:13: result of fmt.Sprintf call not used
errcheck:
go build: built, no errors
go vet: no findings
ineffassign:
go build: built, no errors
go vet: no findings
copylock, run: c.Inc(), then c.n -> 0
The boundary runs along types. The compiler checks that the program is correct
by types: Printf takes ...any, and a string there is legal. That the
%d verb expects a number is known only to whoever reads the format string —
and that is what vet does. A copy of a mutex is also legal by types: Counter
is an ordinary struct and can be copied. But a value receiver means Inc
works on a copy of both the lock and the counter: in the run the counter
stayed 0 after c.Inc(). The program does not crash — the method just
silently does nothing.
vet let the last two programs through: os.Remove with its error discarded,
and the assignments limit := 10 and limit = 20, which are overwritten
without ever being read.
Third-party linters catch those. golangci-lint with its default linters:
6) golangci-lint BEYOND go vet
──────────────────────────────
golangci-lint has version 2.5.0 built with go1.25.1 from ff63786c on 2025-09-21T19:04:05Z
golangci-lint run --no-config (default linters):
errcheck:
main.go:7:11: Error return value of `os.Remove` is not checked (errcheck)
ineffassign:
main.go:7:2: ineffectual assignment to limit (ineffassign)
main.go:8:2: ineffectual assignment to limit (ineffassign)
golangci-lint caught the other five programs too — with the same vet: its
govet linter produced the same messages in the run, tagged (govet), and the
unused variable came tagged (typecheck), that is, as a compiler error. Beyond
vet, errcheck and ineffassign fired in the run. golangci-lint is a
third-party program, not part of the Go distribution, and its set of linters is
configured by the project; go vet needs nothing but Go.
Mechanism 4: go test runs vet — but not all of it
Many are sure that if go test passed, vet has passed too. The go test
documentation clarifies:
Only a high-confidence subset of the default go vet checks are used. That
subset is: atomic, bool, buildtags, directive, errorsas, ifaceassert, nilfunc,
printf, stringintconv, and tests.
printf is on the list, copylocks is not. Two packages with tests: one has
fmt.Sprintf with a wrong verb, the other the same Counter with a value
receiver:
5) go vet INSIDE go test
────────────────────────
go test ./testvet/printf/
testvet/printf/greet.go:7:9: fmt.Sprintf format %d has arg name of wrong type string
FAIL example.com/toolcases/testvet/printf [build failed]
FAIL
go test -v ./testvet/copylock/
=== RUN TestInc
counter_test.go:8: after Inc: 0
--- PASS: TestInc
PASS
ok example.com/toolcases/testvet/copylock
go test -vet=all ./testvet/copylock/
testvet/copylock/counter.go:11:9: Inc passes lock by value: example.com/toolcases/testvet/copylock.Counter contains sync.Mutex
FAIL example.com/toolcases/testvet/copylock [build failed]
FAIL
The first package never reached its tests: [build failed] — for go test,
vet is part of the build, and a finding stops it. The second passed green,
even though the test itself printed after Inc: 0. With -vet=all the same
package fails.
Why a subset? Judging by the words "high-confidence", go test must not fail
on false positives, and vet, as said above, is heuristic. The conclusion for a
project is simple: go vet ./... in CI is a separate step, not "already part
of go test". The converse is useful too: if a test fails with
[build failed] while the compiler is happy, that is vet, and the message
above the FAIL line says which check.
Mechanism 5: build constraints and cross-compilation
One package can contain files for different platforms. Which of them go into
the build is decided by two mechanisms: the file name suffix and the
//go:build line.
If a file's name, after stripping the extension and a possible _test suffix,
matches any of the following patterns: *_GOOS *_GOARCH *_GOOS_GOARCH
(example: source_windows_amd64.go) where GOOS and GOARCH represent any known
operating system and architecture values respectively, then the file is
considered to have an implicit build constraint requiring those terms (in
addition to any explicit constraints in the file).
cases/tags has six files: platform_linux.go and platform_windows.go — by
suffix; platform_other.go — with the line //go:build !linux && !windows;
debug_on.go and debug_off.go — with the lines //go:build debug and
//go:build !debug; the sixth is main.go, unconstrained:
//go:build debug
package main
const debug = trueWhat goes into the package is shown by go list:
7) BUILD CONSTRAINTS: FILE NAME AND //go:build
──────────────────────────────────────────────
files in tags/: debug_off.go debug_on.go main.go platform_linux.go platform_other.go platform_windows.go
linux (default) [debug_off.go main.go platform_linux.go]
GOOS=windows [debug_off.go main.go platform_windows.go]
GOOS=darwin [debug_off.go main.go platform_other.go]
-tags debug [debug_on.go main.go platform_linux.go]
Of six files, three go into each build. The platform() function is declared
three times — and that is not an error, because exactly one declaration goes
into any one build. -tags debug adds a user tag, and debug_on.go is taken
instead of debug_off.go. By go help buildconstraint, the //go:build line
must come before package and must be followed by a blank line — so that it
is not taken for package documentation.
The catch with the suffix is that it fires even without the author meaning
it. A file handler_windows.go, named that way "because it is about UI
windows", will not go into the build on Linux — just like
platform_windows.go in the run — and everything declared in it simply does
not exist for the rest of the package.
The same GOOS and GOARCH variables also switch the target platform of the
whole build:
8) CROSS-COMPILATION: GOOS AND GOARCH
─────────────────────────────────────
GOOS=linux GOARCH=amd64 CGO_ENABLED=1 compile: 0 ELF, EM_X86_64 2 224 064 bytes
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 compile: 55 ELF, EM_AARCH64 2 252 642 bytes
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 compile: 57 PE, IMAGE_FILE_MACHINE_AMD64 2 306 560 bytes
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 compile: 54 Mach-O, CpuArm64 2 335 714 bytes
On one linux/amd64 machine, with one go build command, we got an ELF for ARM,
a PE for Windows and a Mach-O for macOS on Apple Silicon. The compile column
shows how this is possible: for a new GOOS/GOARCH pair the standard library
is compiled from source by the same compiler (55, 57 and 54 packages), while
for the native linux/amd64 everything was already in the cache. No separate
"cross toolchain" is needed.
There is one condition, and it is in the CGO_ENABLED column: 1 on the native
platform, 0 when cross-compiling.
It is disabled by default when cross-compiling as well as when the CC
environment variable is unset and the default C compiler (typically gcc or
clang) cannot be found on the system PATH.
As long as the program does not use cgo, cross-compiling is two environment variables. As soon as C code appears among the dependencies, you need a C compiler for the target platform — and that is no longer "two variables".
Deeper: what -ldflags does — the linker substitutes strings and drops symbols
The -ldflags flag passes arguments to the linker (go tool link). In an
interview it comes up in two pairings: the version in the binary, and its size.
The version at build time — -X. The linker can write a string into a
package's string variable:
Set the value of the string variable in importpath named name to value. This
is only effective if the variable is declared in the source code either
uninitialized or initialized to a constant string expression. -X will not work
if the initializer makes a function call or refers to other variables.
The program cases/stamp has three variables for the three cases of this
quote:
// An uninitialized string: -X will fill it.
var commit string
// Initialized with a constant string: -X will replace it.
var version = "dev"
// Initialized with a function call: -X will not work — and will say nothing.
var built = defaultBuilt()
func defaultBuilt() string { return fmt.Sprint("unknown") }9) -ldflags: VERSION VIA -X AND SIZE WITH -s -w
───────────────────────────────────────────────
go run ./stamp
version = dev
commit =
built = unknown
go run -ldflags "-X main.version=1.4.2 -X main.commit=9f3c2ab -X main.built=2026-10-14" ./stamp
version = 1.4.2
commit = 9f3c2ab
built = unknown
go run -ldflags "-X main.verison=1.4.2" ./stamp (typo in the name)
version = dev
commit =
built = unknown
exit code: 0
Two traps are visible at once. built stayed unknown: the initializer calls
a function, and -X left it alone — without a single message. And the typo
verison instead of version gave exit code 0: there is no variable with that
name, and the linker silently did nothing. In CI this means a broken build line
will ship a binary with version dev, and only someone who asks the binary for
its version will notice. The protection is cheap: after the build, run
./app -version and compare with what is expected.
The size — -s -w. By the documentation -s drops the symbol table and
debug information, -w the DWARF table. In the run that is 2 224 064 bytes
against 1 446 072 — 1.54 times smaller. And -s already implies -w — "Implies
the -w flag" — and -ldflags "-s" in the run gave the same 1 446 072 bytes:
-s -w is a habit rather than a necessity. What does the saving cost? The debugger has
nothing to read: there are no .symtab and .debug_info sections in the
file. But a panic trace remains:
panic in a binary built with -s -w:
exe: <tmp>/hello-sw
panic: boom
goroutine 1 [running]:
main.main()
<cases>/hello/main.go:18 +0x<N>
exit code: 2
The function name and the source line are in place: the runtime keeps them in
its own table, separate from the ELF symbol table and from DWARF — otherwise
they would have nowhere to come from after -s -w. So -s -w removes what
the debugger needs but leaves panics readable.
How to answer in an interview
Short answer: go build compiles and leaves a binary, go run builds into
a temporary directory without debug information and runs it; both take
packages from the build cache, whose key is the content of the inputs. gofmt
puts code into the one canonical format, and nobody argues with it. go vet
catches errors the compiler lets through — a wrong Printf verb, a copied
mutex, unreachable code; go test runs only part of its checks, so
go vet ./... in CI is a separate step.
That is enough to answer correctly. What follows is what gets added if the interviewer keeps digging.
If the interviewer digs deeper
On "why is the second build instant" — the cache and its key. "In the run
a cold build called the compiler 55 times — almost all of them the standard
library — and
a repeated one not once. The key is the content of the inputs: touch does not
reset the cache, editing a comment recompiles the package. If the binary has
already been built from the same inputs, go build does not call even the
linker."
On "why is go run worse than go build in CI" — the exit code and
debugging. "go run builds without DWARF and symbols — the binary matched
-ldflags "-s -w" in size. And it does not pass on the program's exit code:
os.Exit(3) became 1."
On "the code compiles — why vet" — the boundary along types. "The compiler
checks types. Printf takes ...any, and copying a struct with a mutex is
legal. That %d expects a number and that a value receiver copies the lock —
that is vet. And an ignored error is not seen even by vet — you need
errcheck."
On "how do you build for Windows" — GOOS and cgo. "GOOS=windows go build — the standard library will be compiled for the target by the same
compiler. It works as long as there is no cgo: it is off by default when
cross-compiling, and C code needs a C compiler for the target. Files for a
platform are chosen by the name suffix or //go:build."
On "how do you embed a version" — -X and its silence. "-ldflags "-X main.version=…" — the variable must be a string without a function call in
its initializer. A typo in the name gives no error, so after the build it is
worth asking the binary for its version."
Next they ask
What does go generate do, and why do you have to run it by hand?
It looks for //go:generate command arguments comments in files and runs
those commands — nothing more. It produces no code itself:
Go generate is never run automatically by go build, go test, and so on. It must
be run explicitly.
10) go generate: ONLY WHEN CALLED EXPLICITLY
────────────────────────────────────────────
go build .
./main.go:9:28: undefined: codes
go generate -x .
go run gen.go
files after: codes_gen.go gen.go main.go
go run .
codes: 3
main.go refers to codes, which the generator writes — before
go generate the build fails with undefined. That is why generated files are
kept in the repository: whoever runs go build or go install
does not run the generator and may not even have it. By the convention in
go help generate, a generated file carries, before the first code, the line
// Code generated … DO NOT EDIT. — from it tools and people understand that
editing it by hand is pointless.
If you build with -s -w, does a panic become unreadable?
No. In the run a binary built with -ldflags "-s -w" printed main.main() and
<cases>/hello/main.go:18 on panic — the function name and the line. The
runtime keeps the table of functions and lines separately from the ELF symbol
table and from DWARF: it needs it itself, to print the stack.
What is lost is what the debugger uses: there are no .symtab and
.debug_info sections in the file. That is why -s -w goes on a production
build, and for debugging you build without it. The source path in the trace,
by the way, is absolute — by the go build documentation the -trimpath flag
removes all file system paths from the binary.
Common misconceptions
If the code compiled, go vet will find nothing
The compiler checks types, not the meaning of format strings and not the copying of locks. In the run Printf("%d") with a string, a copy of sync.Mutex, unreachable code and a discarded fmt.Sprintf built without errors — go vet caught all four.
go test already runs go vet, a separate step is not needed
It runs a subset of ten checks: printf is in it, copylocks is not. The test of the package with a copied mutex passed and printed after Inc: 0; with -vet=all — [build failed].
go run builds the same as go build, it just runs it right away
It builds without DWARF and the symbol table: the binary matched go build -ldflags "-s -w" in size. It puts it into a temporary directory, runs it from the cache the second time (in go1.24.7 and go1.25.1) — and does not pass on the program's exit code: os.Exit(3) became code 1.
The opening brace can go on a new line, that is a matter of style
It is a syntax error: after ) at the end of a line the lexer inserts a semicolon. gofmt does not format such a file but reports unexpected semicolon or newline before.
To build a Windows binary you need Windows or a cross-compiler
GOOS=windows go build is enough: the standard library is built for the target by the same compiler, and the run produced a PE file. There is one condition — no cgo: it is off by default when cross-compiling.
If -X does not find the variable, the build fails
It does not: the typo main.verison gave exit code 0 and version dev. Just as silently -X skips a variable whose initializer calls a function.
Practice
Two tasks. Answer first, then check against the real output: in both, the correct answer is taken from a run of the script, not assigned.
Practice · predict the output
package main
import "fmt"
const base = "dev"
var tag = "v0"
var commit string
var version = base + "-local"
var channel = tag
func main() {
fmt.Println(version, channel, commit)
}
// go build -ldflags "-X main.version=1.4.2 -X main.channel=stable -X main.commit=9f3c2ab"Practice · estimate
Knowledge check
Method Inc is declared with a value receiver on a struct with a field of type sync.Mutex. go build passes, go test passes. What will go vet say?
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
gois one command that builds, runs, formats and checks code.go buildcompiles packages and links a binary,go rundoes the same into a temporary directory and runs the result straight away,gofmtputs code into the one canonical format, andgo vetlooks for errors the compiler lets through. All of it ships with the standard Go distribution — nothing to install separately.- The compiler lets many real errors through, and
go testdoes not catch all of them.Printf("%d")with a string, a copy ofsync.Mutexin a value receiver, unreachable code, a discardedfmt.Sprintfresult — in the run all of it built without a single error, andgo vetcaught all four. Butgo testruns only part of vet's checks: the test of the package with the copied mutex passed and printedafter Inc: 0— the counter never went up. - Beyond that comes what separates real understanding from having read about it. A cold build of a program using
fmtandoscalled the compiler 55 times, a repeated one not once and finished tens of times faster: the packages sit in the cache, and the cache key is the content of the files, not their modification time.go runbuilds without debug information — its binary matched the size ofgo build -ldflags "-s -w"to the byte — and turns the program's exit code 3 into its own 1. An opening brace on its own line is a syntax error, not a style choice.GOOS=windows go buildon Linux produced a PE file without a single external tool. And-ldflags "-X …"silently passes over a typo in the variable name: the version stayeddev, exit code 0.
In fact
- The compiler checks types, not the meaning of format strings and not the copying of locks. In the run
Printf("%d")with a string, a copy ofsync.Mutex, unreachable code and a discardedfmt.Sprintfbuilt without errors —go vetcaught all four. - It runs a subset of ten checks:
printfis in it,copylocksis not. The test of the package with a copied mutex passed and printedafter Inc: 0; with-vet=all—[build failed]. - It builds without DWARF and the symbol table: the binary matched
go build -ldflags "-s -w"in size. It puts it into a temporary directory, runs it from the cache the second time (in go1.24.7 and go1.25.1) — and does not pass on the program's exit code:os.Exit(3)became code 1. - It is a syntax error: after
)at the end of a line the lexer inserts a semicolon.gofmtdoes not format such a file but reportsunexpected semicolon or newline before. GOOS=windows go buildis enough: the standard library is built for the target by the same compiler, and the run produced a PE file. There is one condition — no cgo: it is off by default when cross-compiling.- It does not: the typo
main.verisongave exit code 0 and versiondev. Just as silently-Xskips a variable whose initializer calls a function.
What is covered
- What is really being asked
- Base: one command, several subcommands
- Mechanism 1: the build cache — and what `go run` really does
- Mechanism 2: `gofmt` — a format that is not up for discussion
- Mechanism 3: what the compiler lets through and `go vet` catches
- Mechanism 4: `go test` runs vet — but not all of it
- Mechanism 5: build constraints and cross-compilation
- Deeper: what `-ldflags` does — the linker substitutes strings and drops symbols
- How to answer in an interview
- Next they ask
- Common misconceptions
- Practice
- Knowledge check
Sources & further reading
11 SOURCES
- The go command — go runOfficial documentation. How the go run binary differs from go build: «By default, 'go run' compiles the binary without generating the information used by debuggers, to reduce build time». And about the exit code: «The exit status of Run is not the exit status of the compiled binary».https://pkg.go.dev/cmd/go#hdr-Compile_and_run_Go_program
- The go command — build and test cachingOfficial documentation. What the cache accounts for: «The build cache correctly accounts for changes to Go source files, compilers, compiler options, and so on: cleaning the cache explicitly should not be necessary in typical use». Where it lives: «running 'go env GOCACHE' prints the current cache directory».https://pkg.go.dev/cmd/go#hdr-Build_and_test_caching
- The vet commandOfficial documentation. Why vet when there is a compiler, and what not to expect from it: «Vet uses heuristics that do not guarantee all reports are genuine problems, but it can find errors not caught by the compilers».https://pkg.go.dev/cmd/vet
- The go command — go test and vetOfficial documentation. Which vet checks go test runs: «Only a high-confidence subset of the default go vet checks are used. That subset is: atomic, bool, buildtags, directive, errorsas, ifaceassert, nilfunc, printf, stringintconv, and tests». What happens on a finding: «If go vet finds any problems, go test reports those and does not run the test binary». The text is the same in go help test of go1.24.7 and go1.25.1.https://pkg.go.dev/cmd/go#hdr-Test_packages
- The go command — build constraintsOfficial documentation. The file name suffix: «If a file's name, after stripping the extension and a possible _test suffix, matches any of the following patterns: *_GOOS *_GOARCH *_GOOS_GOARCH (example: source_windows_amd64.go) where GOOS and GOARCH represent any known operating system and architecture values respectively, then the file is considered to have an implicit build constraint requiring those terms (in addition to any explicit constraints in the file)».https://pkg.go.dev/cmd/go#hdr-Build_constraints
- The link command — the -X, -s, -w flagsOfficial documentation. The -X flag: «Set the value of the string variable in importpath named name to value. This is only effective if the variable is declared in the source code either uninitialized or initialized to a constant string expression. -X will not work if the initializer makes a function call or refers to other variables». The -s flag: «Omit the symbol table and debug information. Implies the -w flag, which can be negated with -w=0»; the -w flag: «Omit the DWARF symbol table».https://pkg.go.dev/cmd/link
- The gofmt commandOfficial documentation. What gofmt does: «Gofmt formats Go programs. It uses tabs for indentation and blanks for alignment». And what it works on: «Given a file, it operates on that file; given a directory, it operates on all .go files in that directory, recursively».https://pkg.go.dev/cmd/gofmt
- The cgo command — cross-compilingOfficial documentation. Why cross-compiling needs no C compiler: «It is disabled by default when cross-compiling as well as when the CC environment variable is unset and the default C compiler (typically gcc or clang) cannot be found on the system PATH».https://pkg.go.dev/cmd/cgo
- The Go Specification — SemicolonsOfficial documentation. Why the opening brace cannot be moved: «When the input is broken into tokens, a semicolon is automatically inserted into the token stream immediately after a line's final token if that token is» — followed by a list that includes an identifier and the closing parenthesis «)».https://go.dev/ref/spec#Semicolons
- cmd/go/internal/run/run.go and work/gc.go, go1.24.7Go source code. How go run builds the binary: run.go, line 148 —
p.Internal.OmitDebug = true; line 172 —a1.CacheExecutable = true(line 170 in go1.25.1). In work/gc.go, with OmitDebug the package is compiled with-dwarf=false(lines 110–111), and-sand-ware passed to the linker (lines 612–613).https://github.com/golang/go/blob/go1.24.7/src/cmd/go/internal/run/run.go - The go command — go generateOfficial documentation. When generation runs: «Go generate is never run automatically by go build, go test, and so on. It must be run explicitly».https://pkg.go.dev/cmd/go#hdr-Generate_Go_files_by_processing_source