Go's tooling is unusual because most of it ships with the language. go test, go build, go vet, go mod, the profiler, and the race detector are all built in. This lesson covers the bits you use every day.
Project Layout
There is no enforced layout, but a widely accepted convention:
myapp/
├── go.mod
├── go.sum
├── cmd/
│ ├── server/
│ │ └── main.go # binary: myapp-server
│ └── cli/
│ └── main.go # binary: myapp-cli
├── internal/ # private packages — Go enforces this
│ ├── store/
│ ├── handlers/
│ └── config/
├── pkg/ # public packages (use sparingly)
├── api/ # OpenAPI / protobuf schemas
├── deploy/ # Helm charts, k8s manifests
└── Makefile
The internal/ directory has compiler-enforced visibility: anything inside internal/ can only be imported by code in the same module tree. Use it generously — most code should be in internal/.
Modules
A module is a collection of packages versioned together. go.mod declares the module path and dependencies; go.sum records cryptographic hashes for reproducible builds.
module github.com/acme/myapp
go 1.23
require (
github.com/spf13/cobra v1.8.1
github.com/aws/aws-sdk-go-v2 v1.32.4
github.com/stretchr/testify v1.10.0
)
require (
// indirect dependencies pulled in automatically
github.com/davecgh/go-spew v1.1.1 // indirect
)
Common commands:
go mod init github.com/acme/myapp # start a new module
go get github.com/spf13/cobra@v1.8.1 # add or upgrade a dep
go get -u ./... # upgrade all to latest minor/patch
go mod tidy # remove unused; add missing
go mod download # populate the module cache
go mod why github.com/foo/bar # explain why a dep is needed
Testing
Tests live next to the code they test, in files ending _test.go:
// store/memory.go
package store
func (m *MemoryStore) Get(key string) (string, error) { ... }
// store/memory_test.go
package store
import "testing"
func TestGet(t *testing.T) {
s := NewMemoryStore()
s.Put("k", "v")
got, err := s.Get("k")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "v" {
t.Errorf("got %q, want %q", got, "v")
}
}
go test ./... # all packages
go test ./store -v # one package, verbose
go test -run TestGet ./store # one test by name
go test -race ./... # with race detector
go test -cover ./... # with coverage
go test -coverprofile=c.out ./...; go tool cover -html=c.out
Table-Driven Tests
The dominant idiom. One test function, a table of cases:
func TestDivide(t *testing.T) {
cases := []struct {
name string
a, b float64
want float64
wantErr bool
}{
{"basic", 10, 2, 5, false},
{"zero divisor", 1, 0, 0, true},
{"negative", -10, 2, -5, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := Divide(tc.a, tc.b)
if (err != nil) != tc.wantErr {
t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
}
if got != tc.want {
t.Errorf("got %v, want %v", got, tc.want)
}
})
}
}
t.Run creates a sub-test — each case shows up as its own pass/fail in the output. Easy to grep, easy to extend.
Testify and Other Assertion Libraries
The standard library deliberately omits assertions — the style is "compare and call t.Errorf." Many teams use testify for shorter assertions:
import "github.com/stretchr/testify/assert"
assert.Equal(t, "v", got)
assert.NoError(t, err)
assert.Contains(t, names, "alice")
Both styles are common; pick one per project and stick with it.
Benchmarks
func BenchmarkParse(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _ = Parse(input)
}
}
go test -bench=. -benchmem ./...
Output includes ns/op and allocs/op. The b.N is auto-tuned by the framework. -cpuprofile, -memprofile, and -trace integrate with go tool pprof.
Profiling
For long-running services, expose pprof over HTTP:
import _ "net/http/pprof"
go func() { http.ListenAndServe("localhost:6060", nil) }()
Then attach:
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
go tool pprof http://localhost:6060/debug/pprof/heap
go tool pprof http://localhost:6060/debug/pprof/goroutine
Inside pprof: top, list FunctionName, web (renders a graph). pprof's flame graphs are also one of the better tools in the ecosystem.
Linting and Formatting
gofmt is built in and non-negotiable — every Go file in every repository is gofmt'd. Editors do this on save. goimports goes further and manages your import blocks.
For static analysis run go vet (built in) and golangci-lint (the meta-linter that bundles 50+ analyzers). A typical config:
# .golangci.yml
linters:
enable:
- errcheck
- gosimple
- staticcheck
- revive
- govet
- ineffassign
- unconvert
- misspell
Wire it into CI and your IDE.
Building Binaries
go build ./cmd/server # produce ./server
go build -o /tmp/server ./cmd/server
# Smaller binary (strip debug info)
go build -ldflags="-s -w" ./cmd/server
# Inject a version
go build -ldflags="-X main.version=$(git describe)" ./cmd/server
# Cross-compile
GOOS=linux GOARCH=amd64 go build ./cmd/server
GOOS=darwin GOARCH=arm64 go build ./cmd/server
GOOS=windows GOARCH=amd64 go build ./cmd/server
# Fully static (no cgo) — required for FROM scratch containers
CGO_ENABLED=0 GOOS=linux go build ./cmd/server
Container Images
Two-stage Dockerfile, the standard pattern:
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
USER nonroot
ENTRYPOINT ["/server"]
Final image is ~12-30 MB, no shell, no package manager, non-root.
Tools Worth Knowing
- goimports — gofmt + automatic import management
- golangci-lint — the linter meta-tool
- goreleaser — release automation: cross-compile, package, sign, publish
- controller-tools — code-gen for Kubernetes operators
- wire — compile-time dependency injection
- zap / zerolog — structured logging
- mockgen / moq — interface mocks for testing
The toolchain is small and intentional. Once you internalise it, day-to-day Go development is one of the smoothest experiences in mainstream programming.