Deep Engineering
Intermediate·Published·45 MIN

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.

Where to start
Before this lesson it is enough to understand
  • how to write a program in package main and run it;
  • what a module and the go.mod file are;
  • that a method can have a value receiver or a pointer receiver.
You do not need to know in advance
  • the build cache and the difference between go run and go build;
  • what go vet catches, which of its checks go test runs and what golangci-lint adds;
  • build constraints, cross-compilation, the -ldflags flags and go generate.

What is really being asked

The ladder is almost always this one:

  1. "How does go run differ from go build?" — the warm-up; "run also runs it" is correct but incomplete.
  2. "Why is the second build instant?" — about the build cache.
  3. "Why gofmt, and can you argue with it?" — checks whether the candidate understands why Go has a single format.
  4. "The code compiles — why go vet on top?" — the main question: what the compiler lets through and which of it vet catches.
  5. "How do you build a Windows binary on Linux?" — about GOOS, GOARCH and build constraints.
  6. "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

language contractThe behaviour of the subcommands is the documentation of the go command (go help build, go help run, go help test) and of the vet, gofmt and link commands. The run of bench/gotool/internals.go illustrates it rather than establishes it.

Everything asked about is a subcommand of one program, go, plus one separate utility, gofmt, from the same distribution:

commandwhat it doeswhat is left after it
go buildcompiles packages and dependencies; for package main links a binaryan executable in the current directory (or at -o) — for package main only
go runthe same, into a temporary directory, and runs it right awaynothing in the project directory
go testbuilds a test binary, runs part of vet's checks, runs the testsa result in the test cache
go vetstatic checks for suspicious constructsmessages only
gofmt -l / go fmtlists / fixes files whose format differs from the canonical onego fmt rewrites the files
go generateruns commands from //go:generate commentswhatever 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:

measured observationbench/gotool/internals.go, section 1, go1.24.7 linux/amd64, 2 cores; best of 3 runs (the cold cache is cleared before each) and of 5 (warm). The seconds do not carry over to another machine; what carries over is that a warm build does not call the compiler at all.
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.

implementation detail · Go 1.24The build ID design is described in a comment in cmd/go/internal/work/buildid.go, lines 68–92 (go1.24.7): a binary stores an expanded build ID, and go uses it to decide that linking need not be repeated. The run shows only the consequence — link: 0.

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.

— The go command — build caching

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.

— The go command — go run
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.

implementation detail · Go 1.24Checked against the go1.24.7 source: cmd/go/internal/run/run.go, line 148 — OmitDebug, line 172 — CacheExecutable; work/gc.go, lines 110–111 and 612–613. In go1.25.1 the behaviour is the same (CacheExecutable is on line 170). The lesson did not check earlier versions.

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.

— The go command — go run
  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.

— The gofmt command

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:

GO
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

— The Go Specification — Semicolons

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

— The vet command

Seven test programs, one error in each. For example, printf and copylock:

GO
name := "gopher"
fmt.Printf("hello, %d\n", name)
GO
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.

— The go command — go test

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

— The go command — build constraints

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
//go:build debug
 
package main
 
const debug = true

What 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:

measured observationbench/gotool/internals.go, section 8, go1.24.7 on linux/amd64. Binary sizes depend on the Go version: on go1.25.1 the same programs are 1–2% larger, and 58–62 packages are compiled.
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.

— The cgo command

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

language contractThe -X, -s and -w flags are described in the documentation of the link command (go doc cmd/link); the run of bench/gotool/internals.go, section 9, go1.24.7, shows them on the program cases/stamp.

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 link command — the -X flag

The program cases/stamp has three variables for the three cases of this quote:

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

Next they ask

What does go generate do, and why do you have to run it by hand?

Short answer

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.

— The go command — go generate
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.

Next they ask

If you build with -s -w, does a panic become unreadable?

Short answer

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

Claim

If the code compiled, go vet will find nothing

Actually

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.

Claim

go test already runs go vet, a separate step is not needed

Actually

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

Claim

go run builds the same as go build, it just runs it right away

Actually

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.

Claim

The opening brace can go on a new line, that is a matter of style

Actually

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.

Claim

To build a Windows binary you need Windows or a cross-compiler

Actually

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.

Claim

If -X does not find the variable, the build fails

Actually

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

The program is built with the command from the last line. The lesson did not show this case — recall the -X documentation quote in full. What will the program print?
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

Two programs, go build without flags, go1.24.7, linux/amd64: hello with a single fmt.Println and a ten-line HTTP server on net/http. How many times larger is the server binary?
times

Knowledge check

Question 1 of 5

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?

Sources & further reading

11 SOURCES

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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
  7. 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
  8. 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
  9. 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
  10. 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 -s and -w are passed to the linker (lines 612–613).https://github.com/golang/go/blob/go1.24.7/src/cmd/go/internal/run/run.go
  11. 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