The Go Toolchain
The go command is a Swiss Army knife
Section titled “The go command is a Swiss Army knife”The go binary ships every tool a Go developer needs in a single executable. There is no separate build system, no Makefile required for basic workflows, and no plugin ecosystem to configure. The following commands cover 90% of daily Go development.
go run — compile and execute in one step
Section titled “go run — compile and execute in one step”go run compiles the specified files and runs the resulting binary immediately. It is perfect for scripts and quick experiments — no output file is left on disk.
go run main.gogo run . # run the package in the current directorygo run ./cmd/server # run a specific cmd sub-packagego build — produce a binary
Section titled “go build — produce a binary”go build compiles the package and writes the binary to disk. Without a -o flag the binary is named after the last element of the package path (./cmd/server → server); at the module root that is the module’s last path element (go build . in github.com/yourname/myapp writes myapp).
go build . # build current package, output: ./myappgo build -o bin/server ./cmd/server # build to a specific pathgo fmt — canonical formatting
Section titled “go fmt — canonical formatting”go fmt runs gofmt on all .go files in a package and writes the result back to disk. There is exactly one way to format Go code.
go fmt ./... # format every package in the modulegofmt -w -l ./ # equivalent: write changes, list modified filesIn CI, check that code is already formatted:
gofmt -l . | grep -q . && echo "unformatted files found" && exit 1 || echo "all formatted"go vet — the analyzers that catch AI-written bugs
Section titled “go vet — the analyzers that catch AI-written bugs”go vet is not one check — it is a suite of independent analyzers, each with its own name, that the go command runs together. go doc cmd/vet lists all of them; the six below catch patterns that show up constantly in generated code, each verified with a real bug and go vet’s real message:
copylocks — a struct containing a sync.Mutex (or similar) gets copied, so the copy has independent, useless lock state:
type Safe struct { mu sync.Mutex count int}
s := Safe{}s2 := s // copies the mutexmain.go:12:8: assignment copies lock value to s2: example.com/vetdemo/copylocks.Safe contains sync.Mutexlostcancel — the cancel function from context.WithCancel is discarded, leaking the context until its parent is done:
ctx, _ := context.WithCancel(context.Background())main.go:6:7: the cancel function returned by context.WithCancel should be called, not discarded, to avoid a context leakunusedresult — a function whose return value matters (the standard library marks a handful, like errors.New and fmt.Errorf) is called and the result thrown away:
errors.New("boom")main.go:9:2: result of errors.New call not usedhttpresponse — the response from http.Get is used (here, defer resp.Body.Close()) before the error is checked, so a failed request dereferences a nil *http.Response:
resp, err := http.Get("http://example.com")defer resp.Body.Close()if err != nil { fmt.Println(err) return}main.go:10:8: using resp before checking for errorswaitgroup (Go 1.25) — wg.Add(1) is called from inside the goroutine it is meant to track, which races with wg.Wait() in the parent — Add must happen before the go statement:
go func() { wg.Add(1) defer wg.Done()}()wg.Wait()main.go:8:9: WaitGroup.Add called from inside new goroutineprintf — a format verb does not match its argument’s type:
fmt.Printf("%d\n", "hello")main.go:6:14: fmt.Printf format %d has arg "hello" of wrong type stringgo vet ./...go vet exits with a non-zero status if it finds problems, making it easy to fail a CI pipeline. It also runs a small, fixed subset of these analyzers automatically before every go test and go build — hostport (Go 1.25) suggests net.JoinHostPort instead of fmt.Sprintf("%s:%d", host, port), which breaks on IPv6, and stdversion (Go 1.27) reports calls to standard-library APIs newer than your module’s go line.
staticcheck and golangci-lint v2
Section titled “staticcheck and golangci-lint v2”go vet is deliberately conservative — it only reports issues it can prove are almost certainly bugs. staticcheck and golangci-lint run far more analyzers, including many with false-positive risk that go vet avoids.
staticcheck (honnef.co/go/tools/cmd/staticcheck) runs standalone and reports its own SA-prefixed codes. On the same printf and unusedresult bugs above, verified locally (staticcheck 2026.2.1):
main.go:6:13: Printf format %d has arg #1 of wrong type string (SA5009)main.go:9:2: New doesn't have side effects and its return value is ignored (SA4017)golangci-lint bundles dozens of linters — including go vet itself, staticcheck, and many more — behind one command and one config file. Version 2’s config format (verified locally against golangci-lint 2.13.2) starts from:
version: "2"linters: default: standarddefault: standard enables a curated baseline (govet, staticcheck, unused, and others) rather than everything — golangci-lint v1’s default set is gone in v2, and default is now an explicit, named choice. Running it against the copylocks bug above, verified locally, wraps the underlying go vet finding with the linter that reported it and adds an unrelated real issue in the same file:
main.go:12:8: copylocks: assignment copies lock value to s2: ...Safe contains sync.Mutex (govet)main.go:13:6: copylocks: assignment copies lock value to _: ...Safe contains sync.Mutex (govet)main.go:6:2: field mu is unused (unused)main.go:7:2: field count is unused (unused)The (govet) and (unused) suffixes name which underlying linter reported each line — useful when deciding what to suppress and what to fix.
govulncheck
Section titled “govulncheck”govulncheck cross-references your dependencies (and, more precisely, the exact functions your code actually calls) against the Go vulnerability database.
govulncheck ./...Verified locally on a clean module with no third-party dependencies:
No vulnerabilities found.
Your code is affected by 0 vulnerabilities.This scan also found 0 vulnerabilities in packages you import and 8vulnerabilities in modules you require, but your code doesn't appear to callthese vulnerabilities.The distinction in that last line is the entire point of the tool: a vulnerable function existing somewhere in your dependency tree is not the same as your program calling it. govulncheck does call-graph analysis to tell the two apart, which is why it reports far fewer “you must act now” results than a naive “scan go.sum against a CVE list” tool would.
go fix — modernizers (Go 1.26+)
Section titled “go fix — modernizers (Go 1.26+)”go fix was rebuilt in Go 1.26 on the same analysis framework as go vet. Instead of a handful of historical fixers it now ships dozens of modernizers: analyzers that rewrite older idioms to the current standard library.
go fix ./... # apply every modernizer to the modulego fix -diff ./... # preview the rewrite without touching filesTypical rewrites: a hand-written for loop that finds the minimum becomes min(...); sort.Slice becomes slices.Sort; for i := 0; i < n; i++ becomes for i := range n; the wg.Add(1) / go / defer wg.Done() triple becomes wg.Go(...) (waitgroupgo); x := x loop-variable copies are deleted. Go 1.27 adds atomictypes, embedlit, slicesbackward, and unsafefuncs. Library authors can also mark a wrapper with //go:fix inline so go fix inlines the call at every use site — the mechanism the standard library uses to migrate callers off deprecated helpers.
Build tags
Section titled “Build tags”A //go:build line at the top of a file (before the package clause, followed by a blank line) tells the toolchain to include or exclude that file based on a boolean expression of tags. Verified locally with two files differing only in their build tag:
//go:build dev
package main
func banner() string { return "DEV BUILD" }//go:build !dev
package main
func banner() string { return "production" }go run . # productiongo run -tags dev . # DEV BUILDCommon uses: swapping a real implementation for a fake one in tests, excluding platform-specific files (Go also auto-applies tags from a _linux.go / _windows.go filename suffix), or gating code that needs a newer Go version than the module’s floor.
go generate
Section titled “go generate”//go:generate comments mark commands to run on demand — never automatically. Verified locally:
//go:generate echo "generation would run here"go generate ./...# generation would run herego build and go test never invoke go generate for you; it must be a deliberate step in your workflow or CI, typically for stringer-generated String() methods, protobuf code, or mock generation.
-ldflags -X and -trimpath
Section titled “-ldflags -X and -trimpath”-ldflags "-X pkg.var=value" sets the value of a package-level string variable at link time — the standard way to bake a version string or commit hash into a binary without recompiling source:
var version = "dev"
func main() { fmt.Println("version:", version) }Verified locally:
go build -o app . # version: devgo build -ldflags "-X main.version=1.2.3" -o app . # version: 1.2.3The variable must be an uninitialized-at-runtime string (no const, no computed default) and the flag needs the fully-qualified path (main.version, or example.com/mod/pkg.Var for a non-main package).
-trimpath removes local filesystem paths from the compiled binary (they otherwise leak into panic stack traces and debug info) — verified locally to build without error and is standard practice for any binary you distribute:
go build -trimpath -o app .Cross-compilation
Section titled “Cross-compilation”Setting GOOS and GOARCH cross-compiles for another platform — no separate toolchain download needed, since the Go compiler is one cross-compiler for every supported target. Verified locally, building a Linux/arm64 binary from macOS/arm64:
GOOS=linux GOARCH=arm64 go build -o app-linux-arm64 .file app-linux-arm64# app-linux-arm64: ELF 64-bit LSB executable, ARM aarch64 ..., statically linked# Cross-compile a stripped, static Linux/amd64 binaryCGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o bin/server ./cmd/server-ldflags="-s -w" strips the symbol table and DWARF debug info, reducing binary size. CGO_ENABLED=0 guarantees a fully static binary — no dynamic libc dependency, and it is required for most cross-compilation targets since cgo needs a C cross-toolchain that plain go build does not have.
go env
Section titled “go env”go env reads and writes the toolchain’s persistent configuration (stored outside your module, in a per-user config file). The variables that matter most day to day:
| Variable | Purpose |
|---|---|
GOPROXY | Where go get/go mod download fetch modules from (default https://proxy.golang.org,direct) |
GOPRIVATE | Glob patterns of module paths to fetch directly, skipping the proxy and checksum database — for internal/private repositories |
GOSUMDB | The checksum database used to verify downloads (default sum.golang.org); set to off only when you fully trust GOPROXY |
GOFLAGS | Flags implicitly appended to every go command, e.g. GOFLAGS=-mod=mod |
GOTOOLCHAIN | Controls automatic toolchain switching (auto by default) — see the toolchain directive in the Modules lesson |
go env GOPROXY GOPRIVATE GOSUMDB# https://proxy.golang.org,direct## sum.golang.orggo env -w GOFLAGS=-mod=mod writes a setting permanently; go env GOPROXY alone just reads the current value.
gopls and goimports
Section titled “gopls and goimports”gopls is the official Go language server — every major editor’s Go support (VS Code, Neovim, GoLand’s underlying protocol support) talks to gopls for autocomplete, go-to-definition, inline diagnostics, and refactoring. It runs go vet-style checks continuously as you type, which is why many of the bugs in this lesson are visible as a red squiggle before you ever run a command. goimports is gofmt plus automatic import management: it adds missing imports and removes unused ones, and most editors run it in place of plain gofmt on save.
Profile-guided optimization (PGO)
Section titled “Profile-guided optimization (PGO)”Since Go 1.21, placing a CPU profile named default.pgo in a package’s main directory makes go build use it automatically: the compiler inlines more aggressively along the hot paths the profile identifies, typically improving throughput a few percent with no source changes. Generate the profile from a representative production workload (-cpuprofile, the same mechanism as the Benchmarks lesson), commit default.pgo alongside the source, and go build picks it up with no extra flags.
go doc — browse documentation
Section titled “go doc — browse documentation”go doc prints the documentation for any package, type, function, or method — without leaving the terminal.
go doc fmt # package overviewgo doc fmt.Println # specific functiongo doc net/http.Request # type in a sub-packagego doc -all net/http # every exported symbol in the packagego doc -ex strings.Cut # print the runnable examples (Go 1.27)For a browser-based view run go doc -http (Go 1.25+) or browse pkg.go.dev.
Putting it together — a typical CI pipeline
Section titled “Putting it together — a typical CI pipeline”test -z "$(gofmt -l .)" && \go vet ./... && \govulncheck ./... && \go test -race -coverprofile=coverage.out -covermode=atomic ./... && \go build ./...This pipeline catches formatting drift, static bugs (including the six go vet analyzers above), known vulnerabilities, data races, and compilation errors before code is merged.
The quality-gate pipeline
Section titled “The quality-gate pipeline”flowchart TD A["source code"] --> B["gofmt -l (formatting check)"] B --> C["go vet (copylocks, lostcancel, printf, ...)"] C --> D["staticcheck / golangci-lint (broader analysis)"] D --> E["govulncheck (known CVEs actually called)"] E --> F["go test -race (behavior + data races)"] F --> G["go build (compiles to a binary)"]
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
go vet | Catches common mistakes before runtime, built in, zero config | Deliberately conservative — pair it with staticcheck for broader coverage |
golangci-lint | Dozens of linters behind one command and config file | Needs a config file to tune (v2’s default: standard is a reasonable start); slower than go vet alone |
govulncheck | Call-graph aware — flags what you actually call, not just what’s in go.sum | Only as current as the vulnerability database; new CVEs take time to appear |
go build -race | Catches race conditions in the binary | Binary runs 2-10x slower — not for production builds |
PGO (default.pgo) | A few percent throughput, no source changes | Profile can go stale as the codebase changes — needs periodic regeneration |
gofmt (via go fmt) | No formatting debates, consistent across the ecosystem | No configuration — you accept the canonical style |
Common Misconceptions
Section titled “Common Misconceptions”go fmtis just a style preference — gofmt is the de facto standard — most projects enforce it in CI.go vetcatches every bug —go vetchecks a subset of common mistakes it can prove with high confidence — pair it with staticcheck or golangci-lint for broader coverage.go generateruns automatically during a build —go generatemust be run explicitly;go buildnever triggers it — wire it into a Makefile or CI step yourself.- GOPATH is still required — Go modules (
go.mod) fully replaced GOPATH as of Go 1.16. govulncheckreporting a vulnerable dependency means you must patch immediately — it separately reports vulnerabilities in required modules versus ones your code’s call graph actually reaches; only the latter is an active risk.-ldflags -Xcan set any variable, including constants — it can only set an uninitializedstringvariable; aconst, a non-string type, or a variable with a computed initializer are all silently unaffected.
What changed in the toolchain, Go 1.25–1.27
go mod initwrites thegoline as the exact version of the toolchain that ran it — not one minor below. Confirmed locally: Go 1.26.5 writesgo 1.26.5; running it under Go 1.27.0 writesgo 1.27.0. Raise or pin the line explicitly withgo get [email protected]when you need a specific floor.go doc example.com/[email protected](Go 1.27) documents a specific module version, andgo doc -exlists runnable examples.go doc -http(Go 1.25) serves the documentation in a browser;pkgsiteis no longer needed for local use.go tool pprof -http(Go 1.26) opens on the flame-graph view;go tool trace -http=:6060(Go 1.27) binds to localhost only unless you give a full address.go version -m -json(Go 1.24) prints build info as JSON;go build -asan(Go 1.25) reports memory leaks at exit.go fix(Go 1.26) was rebuilt on thego vetanalysis framework with dozens of modernizers; Go 1.27 addsatomictypes,embedlit,slicesbackward, andunsafefuncs.go vet’swaitgroupandhostportanalyzers (Go 1.25) andstdversion, now run bygo testtoo (Go 1.27).
Reading AI-generated code
Section titled “Reading AI-generated code”Generated code compiles, so the compiler’s silence proves nothing. Run the pipeline above before trusting it, and specifically check:
- Does a struct containing a
sync.Mutex,sync.WaitGroup, or similar ever get copied — assigned, passed by value, returned by value, or ranged over as a slice element?go vet’scopylockscatches this, but only if you actually run it. - Is
wg.Add(1)called from inside the goroutine it is meant to track, rather than before thegostatement? This is a real race thatwaitgroupcatches — and a pattern generated code produces constantly because it reads naturally left-to-right. - Does every
context.WithCancel/WithTimeout/WithDeadlinecall’s returnedcancelfunction get called (usually viadefer), or is it discarded with_? - Is an
http.Response(or its.Body) read or closed before the accompanyingerroris checked? - Does a config file claim
version: "2"forgolangci-lintbut list v1-only options, or vice versa — a config that silently no-ops instead of failing? - Has
govulncheckactually been run, or does the generated CI config only includego vetand assume that is sufficient?
🐛 Spot the bug
func fetchAndLog(url string) { resp, err := http.Get(url) defer resp.Body.Close() if err != nil { log.Println("fetch failed:", err) return } log.Println("status:", resp.StatusCode)}Show the answer
defer resp.Body.Close() runs before the err != nil check, but when http.Get fails, resp is nil — so resp.Body panics with a nil pointer dereference on exactly the error path this code was trying to handle gracefully. go vet’s httpresponse analyzer catches this precisely: using resp before checking for errors. The fix is to check the error first and only defer the close once resp is known non-nil: move if err != nil { ...; return } above defer resp.Body.Close().