Module 2: Setup and the Toolchain

Go Setup and Toolchain

Learning objectives

  • Explain the core mental model behind Go Setup and Toolchain
  • Apply Go Setup and Toolchain within Setup and the Toolchain
  • Identify important boundaries, trade-offs, and failure modes
  • Produce concrete evidence from the practice exercise

Related: Go Modules and Packages | Go Testing and Benchmarking | Go Profiling | Rust Setup and Cargo | Go Index


Why the Toolchain Is Part of the Language Argument

Go's originating complaint was build times and dependency chaos (Go What and Why), so the toolchain is not an afterthought — it is half the product. Everything ships in one binary: build, test, format, vet, documentation, dependency management, profiling, and a race detector. There is no Makefile, no CMake, no setup.py, no linter to choose.

The design decision underneath is that there is one right way, and it is the default. gofmt has no options. go test has no framework choice. go build has no configuration file beyond go.mod. This eliminates a category of team argument entirely.

   C++ PROJECT                          GO PROJECT
   ──────────────────────────────       ────────────────────────────
   CMakeLists.txt / Bazel / Meson       go.mod
   vcpkg / Conan / vendored deps        go.mod (same file)
   clang-format + .clang-format         gofmt (no config)
   clang-tidy + config                  go vet (no config)
   gtest / catch2 / doctest             go test (built in)
   valgrind / tsan setup                go test -race
   45-minute build                      3-second build

Verification analogy: compare a flow where every project writes its own compile script against one standardized on a single Makefile convention and filelist format. Standardization is worth more than any individual script's flexibility, because it is what lets an engineer move between projects without re-learning the build.


Installation

# macOS
brew install go

# Linux — prefer the official tarball over distro packages, which lag
curl -LO https://go.dev/dl/go1.23.4.linux-amd64.tar.gz
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.23.4.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin

go version        # go version go1.23.4 linux/amd64
go env            # every setting the toolchain uses

The environment variables that matter:

go env GOPATH      # ~/go — module cache and `go install` binaries. NOT your source tree.
go env GOMODCACHE  # ~/go/pkg/mod — downloaded dependencies, read-only
go env GOBIN       # where `go install` puts binaries; add to PATH
go env GOROOT      # the Go installation itself; never set this manually

The GOPATH confusion is historical. Before Go 1.11, all Go source had to live under $GOPATH/src, which was a genuine annoyance. Since modules, your project lives anywhere and GOPATH is just a cache directory. Any tutorial telling you to put code in $GOPATH/src is pre-2019 and should be ignored.

Toolchain version management is built in since Go 1.21:

// go.mod
go 1.23.0

toolchain go1.23.4      // if the local Go is older, it downloads this one automatically

This solves the same problem as rust-toolchain.toml (Rust Setup and Cargo) — everyone and every CI runner builds with the same compiler.


The First Project

mkdir simtools && cd simtools
go mod init github.com/you/simtools     # module path — usually the repo URL
   simtools/
   ├── go.mod              module path, Go version, dependencies
   ├── go.sum              cryptographic hashes of every dependency
   ├── main.go
   ├── internal/           ← packages here are IMPORTABLE ONLY by this module
   │   └── parser/
   │       └── parser.go
   ├── cmd/                ← convention: one subdirectory per binary
   │   └── triage/
   │       └── main.go
   └── pkg/                ← (optional, somewhat contested) public library code
// main.go
package main

import "fmt"

func main() {
	fmt.Println("hello, sim")
}
go run .              # compile to a temp dir and execute
go build              # produce ./simtools
go build -o bin/tri ./cmd/triage
go install ./cmd/triage   # build and place in $GOBIN

go run . is the fast iteration loop. Because Go compiles in seconds, the workflow feels closer to an interpreted language than to C++ — which was precisely the point.


The Commands That Matter Daily

go build ./...            # compile everything; ./... means "this dir and all subdirs"
go run .                  # build + run
go test ./...             # all tests           → Go Testing and Benchmarking
go test -race ./...       # WITH the race detector — see below
go fmt ./...              # canonical formatting; zero options, zero debate
go vet ./...              # correctness checks the compiler doesn't do
go mod tidy               # add missing deps, remove unused ones
go doc net/http.Client    # documentation in the terminal
go doc -all ./internal/parser
go clean -cache           # nuke the build cache if something is truly wrong
go env -w GOFLAGS=-mod=mod

go vet — the built-in linter

go vet ./...

It catches things the type system cannot: Printf format-string mismatches, unreachable code, struct tags that will not parse, lock values copied by assignment, and — importantly — misuse of sync.Mutex and loop variables. go vet runs automatically as part of go test.

For more, staticcheck is the community standard and finds considerably more:

go install honnef.co/go/tools/cmd/staticcheck@latest
staticcheck ./...

# or golangci-lint, which bundles staticcheck plus ~50 other linters
golangci-lint run

errcheck (bundled in golangci-lint) specifically finds ignored errors — the single most valuable lint in Go, given how easy _ makes it to discard one (Go Errors and Wrapping).

The race detector

This is Go's most important tool, and it is one flag:

go test -race ./...
go run -race .
go build -race
package main

import (
	"fmt"
	"sync"
)

func main() {
	counter := 0
	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() { defer wg.Done(); counter++ }()   // DATA RACE
	}
	wg.Wait()
	fmt.Println(counter)     // prints 87, 94, 100... nondeterministic
}
WARNING: DATA RACE
Write at 0x00c000012028 by goroutine 8:
  main.main.func1()
      /tmp/x/main.go:13 +0x44

Previous write at 0x00c000012028 by goroutine 7:
  main.main.func1()
      /tmp/x/main.go:13 +0x44

Understand exactly what this is and is not. It is a dynamic detector using happens-before analysis on the execution it observes. It finds races on code paths your test actually runs, with the interleaving that actually occurred. It produces no false positives — if it reports a race, it is real — but it produces false negatives for any path you did not exercise.

   RUST                                  GO
   ────────────────────────────          ────────────────────────────────
   data races are a COMPILE ERROR        data races are a RUNTIME finding
   sound: no false negatives             unsound: only finds what you execute
   zero runtime cost                     5-10x slowdown, 5-10x memory
   the program cannot be written         the program compiles and usually works
                                          — until it does not, in production

This is the sharpest concrete difference between the two languages' safety claims (Rust Concurrency and Send Sync). Run -race in CI on every test, always. It costs a slower CI job and finds bugs that would otherwise appear once a month under load.


Dependencies

go get github.com/spf13/cobra@latest
go get github.com/stretchr/[email protected]
go mod tidy               # sync go.mod/go.sum with what the code actually imports
go mod why github.com/x/y # explain why this dependency is in the graph
go mod graph              # the full dependency graph
go list -m all            # every module in the build
go mod vendor             # copy deps into ./vendor for hermetic builds
// go.mod
module github.com/you/simtools

go 1.23.0

require (
	github.com/spf13/cobra v1.8.1
	golang.org/x/sync v0.10.0
)

require (
	github.com/inconshreveable/mousetrap v1.1.0 // indirect
	github.com/spf13/pflag v1.0.5 // indirect
)

Two things distinguish Go's dependency model:

Minimal Version Selection (MVS). Where npm and Cargo resolve to the highest compatible version, Go picks the lowest version that satisfies all requirements. If A needs lib v1.2 and B needs lib v1.5, Go selects v1.5 — the minimum that works, not the latest v1.x. The consequence is that builds are reproducible without a lockfile-driven resolution step, and adding a dependency cannot silently upgrade an unrelated one.

Semantic import versioning. A v2+ module must change its import path:

import "github.com/x/y/v2"       // v2 is part of the PATH, not just the version

This is unusual and initially annoying, but it means v1 and v2 of the same library can coexist in one build — the same property Cargo achieves differently.

go.sum records a cryptographic hash of every module version. A tampered dependency fails the build. The default proxy (proxy.golang.org) plus the checksum database (sum.golang.org) make this verifiable independent of the origin repository.


Cross-Compilation and Build Tags

Go's cross-compilation is the best of any mainstream language, because the standard library is pure Go for the common cases and needs no target toolchain:

GOOS=linux   GOARCH=amd64 go build -o tri-linux-amd64  ./cmd/triage
GOOS=darwin  GOARCH=arm64 go build -o tri-macos-arm64  ./cmd/triage
GOOS=windows GOARCH=amd64 go build -o tri.exe          ./cmd/triage
go tool dist list          # every supported GOOS/GOARCH pair

One command, no cross-toolchain installation, no sysroot. The caveat is cgo: any package using C code needs a real cross-compiler and CGO_ENABLED=1. Setting CGO_ENABLED=0 gives a fully static binary with no libc dependency — which is what you want for a container image or a tool dropped onto an unknown machine.

Build flags worth knowing:

go build -ldflags="-s -w"                          # strip symbols: ~30% smaller binary
go build -ldflags="-X main.version=$(git describe)" # inject a version string at link time
go build -trimpath                                  # remove local paths — reproducible builds
go build -tags=integration                          # enable files gated by a build tag
//go:build integration

package mypkg
// this file is only compiled with -tags=integration

Build tags are Go's ` ifdef ` (Compiler Directives), operating at whole-file granularity. Files named foo_linux.go or foo_arm64.go` are gated automatically by filename convention.


Examples

Example 1: A complete tool, from init to static binary

mkdir logscan && cd logscan
go mod init github.com/you/logscan
// main.go
package main

import (
	"bufio"
	"flag"
	"fmt"
	"os"
	"regexp"
)

var version = "dev"      // overridden at link time

func main() {
	pattern := flag.String("pattern", "UVM_ERROR", "regexp to match")
	limit := flag.Int("limit", 10, "max matches to print")
	showVer := flag.Bool("version", false, "print version and exit")
	flag.Parse()

	if *showVer {
		fmt.Println(version)
		return
	}
	if flag.NArg() != 1 {
		fmt.Fprintf(os.Stderr, "usage: %s [flags] <logfile>\n", os.Args[0])
		flag.PrintDefaults()
		os.Exit(2)
	}

	re, err := regexp.Compile(*pattern)
	if err != nil {
		fmt.Fprintf(os.Stderr, "bad pattern: %v\n", err)
		os.Exit(1)
	}

	f, err := os.Open(flag.Arg(0))
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		os.Exit(1)
	}
	defer f.Close()

	count, shown := 0, 0
	sc := bufio.NewScanner(f)
	for sc.Scan() {
		if re.MatchString(sc.Text()) {
			count++
			if shown < *limit { fmt.Println(" ", sc.Text()); shown++ }
		}
	}
	if err := sc.Err(); err != nil {
		fmt.Fprintf(os.Stderr, "read error: %v\n", err)
		os.Exit(1)
	}
	fmt.Printf("%d match(es)\n", count)
}
go run . -pattern 'UVM_(ERROR|FATAL)' sim.log

CGO_ENABLED=0 go build -trimpath \
  -ldflags="-s -w -X main.version=$(git describe --tags --always)" \
  -o logscan .

ls -lh logscan       # ~2 MB, fully static, no dependencies
./logscan -version

That binary runs on any Linux with a compatible kernel, in a FROM scratch container, with no interpreter and no shared libraries. This deployment story is a large part of why Go took over cloud infrastructure tooling.

Example 2: Catching a real bug with -race

package main

import (
	"fmt"
	"sync"
)

type Scoreboard struct {
	hits map[string]int
}

func main() {
	sb := &Scoreboard{hits: make(map[string]int)}
	var wg sync.WaitGroup

	for i := 0; i < 8; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			for j := 0; j < 1000; j++ {
				sb.hits["total"]++          // ✗ concurrent map write
			}
		}(i)
	}
	wg.Wait()
	fmt.Println(sb.hits["total"])
}

Without -race this usually prints a wrong number, and occasionally crashes with fatal error: concurrent map writes — Go's runtime has a built-in map-corruption detector, but it is best-effort. With -race it reports the exact two goroutines and source lines every time.

The fix, and the reason to know both options:

// option 1: a mutex — right when several goroutines must update shared state
type Scoreboard struct {
	mu   sync.Mutex
	hits map[string]int
}
func (s *Scoreboard) Hit(k string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.hits[k]++
}

// option 2: per-goroutine maps merged at the end — no lock, no contention
// (usually faster; see Go Concurrency Patterns)

Example 3: A Makefile wrapping the Go toolchain for a CAD flow

BIN     := logscan
VERSION := $(shell git describe --tags --always --dirty)
LDFLAGS := -s -w -X main.version=$(VERSION)

.PHONY: all test lint clean install

all: $(BIN)

$(BIN): $(wildcard *.go) go.mod
	CGO_ENABLED=0 go build -trimpath -ldflags="$(LDFLAGS)" -o $@ .

test:
	go test -race -cover ./...

lint:
	go vet ./...
	staticcheck ./...
	gofmt -l . | tee /dev/stderr | (! read)      # fail if anything is unformatted

install: $(BIN)
	install -m 755 $(BIN) /tools/bin/$(BIN)

clean:
	rm -f $(BIN)
	go clean -cache -testcache

Go does not need a Makefile, but a real flow usually has one anyway, and the wrapping is trivial because every Go command is a one-liner. Compare with driving Cargo from Make (Rust Setup and Cargo) — the shape is the same, and both are far easier to integrate than a C++ build.


What Trips People Up

  • Putting source under $GOPATH/src. Obsolete since modules. Your project lives wherever
  • you want.

  • go get for installing tools. Since Go 1.17, go get only manages dependencies. Use
  • go install pkg@version for tools.

  • Forgetting ./.... go build builds the current package only; go build ./... builds
  • everything recursively.

  • Skipping -race. The most valuable flag in the toolchain, and the one most often left
  • out of CI.

  • Fighting gofmt. It has no options on purpose. Configure your editor to run it on save
  • and stop thinking about formatting.

  • Unused imports and variables being compile errors. Not warnings. Deliberate, and it
  • catches real mistakes. Use _ = x during debugging, then remove it.

  • go mod tidy removing something you needed. It removes what is not imported. If a
  • dependency is only used behind a build tag, add //go:build correctly or use a tools.go file with blank imports.

  • Assuming a static binary. Any cgo usage (including the default net and os/user
  • resolvers on some platforms) links libc. CGO_ENABLED=0 forces pure Go.

  • Not knowing about MVS. Go picks the minimum satisfying version. go get -u is what
  • upgrades, and it is a deliberate act.


Related

  • Go Modules and Packages — module paths, internal/, import cycles
  • Go Testing and Benchmarking — go test, table tests, benchmarks
  • Go Profiling — pprof, the other half of the built-in tooling
  • Go Memory Model — what the race detector is actually checking
  • Go Common Pitfalls — the mistakes go vet and staticcheck catch
  • Rust Setup and Cargo — the same problem, solved with a similar philosophy
  • Python Setup and Execution — for contrast on deployment complexity
  • Go Index — full topic map

Practice lab

Build the smallest runnable Go program that demonstrates Go Setup and Toolchain. Add a table-driven test, run the relevant Go diagnostics, inject one realistic failure, and explain the evidence used to correct it.

Review questions

  1. What problem does Go Setup and Toolchain solve, and what assumptions does it rely on?
  2. Which boundary or failure case is easiest to miss, and how would you expose it?
  3. What alternative design would you consider, and what trade-off would change the decision?
  4. What artifact, trace, test, or metric proves that your implementation is correct?

Completion evidence

  • A working artifact, annotated trace, or reproducible experiment
  • At least one normal case and one deliberately failing or boundary case
  • A concise explanation of the design choice and its trade-offs
  • Saved output showing how correctness was evaluated