3 min read
On this page

Build & Dev Tools

Go ships with excellent tooling, and the community fills the gaps. Static analysis catches bugs before tests run. Code generation eliminates boilerplate. Build tags enable conditional compilation. Live reload speeds up development. This topic covers the tools you should have in every Go project.

go vet: Catch Common Mistakes

go vet ships with Go and catches mistakes the compiler misses:

go vet ./...

What it catches:

// Printf format mismatch
fmt.Printf("name: %d", name) // go vet: Printf format %d has arg name of wrong type string

// Unreachable code
func example() int {
    return 42
    fmt.Println("never runs") // go vet: unreachable code
}

// Copying a lock
var mu sync.Mutex
mu2 := mu // go vet: assignment copies lock value

// Struct tag errors
type User struct {
    Name string `json:name` // go vet: struct field tag `json:name` not compatible
}

Run go vet in CI. It has zero false positives and catches real bugs.

staticcheck: Advanced Static Analysis

staticcheck is the most important third-party analysis tool:

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

It catches more than go vet:

// Deprecated function usage
strings.Title("hello") // SA1019: strings.Title is deprecated

// Inefficient string conversion
string(n) // where n is an int -- SA1019: use strconv.Itoa

// Useless assignments
x := doSomething()
x = doSomethingElse() // SA4006: x is overwritten before being used

// Empty branches
if err != nil {
    // SA9003: empty branch
}

staticcheck also enforces style rules and suggests simplifications.

golangci-lint: Meta-Linter

golangci-lint runs many linters in parallel:

go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
golangci-lint run ./...

Configure it with .golangci.yml:

linters:
  enable:
    - errcheck      # check error return values
    - govet         # go vet
    - staticcheck   # advanced analysis
    - unused        # find unused code
    - gosimple      # simplification suggestions
    - ineffassign   # detect useless assignments
    - gocritic      # opinionated suggestions
    - revive        # golint replacement

linters-settings:
  errcheck:
    check-type-assertions: true
  gocritic:
    enabled-tags:
      - diagnostic
      - performance

issues:
  exclude-use-default: false
  max-issues-per-linter: 0
  max-same-issues: 0

Use golangci-lint in CI. It is the standard lint step for Go projects:

# GitHub Actions
- name: Lint
  uses: golangci/golangci-lint-action@v6
  with:
    version: latest

go generate: Code Generation

go generate runs commands embedded in Go source files:

//go:generate stringer -type=Status
//go:generate mockgen -source=store.go -destination=mock_store.go

package mypackage
go generate ./...

Common uses:

  • stringer: Generate String() methods for enum types
  • mockgen: Generate mock implementations for testing
  • sqlc: Generate type-safe Go from SQL queries
  • protoc-gen-go: Generate Go from Protocol Buffers

The convention is to commit generated files. CI should verify that go generate produces no diff:

go generate ./...
git diff --exit-code

Build Tags for Conditional Compilation

Build tags control which files are included in a build:

//go:build linux

package mypackage

// This file is only compiled on Linux
//go:build integration

package store_test

// This file is only compiled with: go test -tags integration

Combine tags with boolean logic:

//go:build (linux || darwin) && !race

Common uses:

  • Separate integration tests from unit tests
  • Platform-specific implementations
  • Debug-only code
  • Feature flags

ldflags: Embedding Version Info

Inject values at build time without modifying source code:

var version = "dev"

func main() {
    fmt.Println("version:", version)
}
go build -ldflags "-X main.version=1.2.3" -o myapp

Multiple flags:

go build -ldflags "\
  -X main.version=$(git describe --tags) \
  -X main.commit=$(git rev-parse --short HEAD) \
  -X main.buildDate=$(date -u +%Y-%m-%d) \
  -s -w" \
  -o myapp

-s strips the symbol table, -w strips DWARF debugging info. Both reduce binary size.

Air: Live Reload in Development

air watches your source files and rebuilds on change:

go install github.com/air-verse/air@latest
air

Configure with .air.toml:

[build]
  cmd = "go build -o ./tmp/main ./cmd/api"
  bin = "./tmp/main"
  delay = 1000 # ms

[build.exclude_dir]
  values = ["tmp", "vendor", "node_modules"]

[build.include_ext]
  values = ["go", "html", "css"]

[log]
  time = true

Now every time you save a .go file, air rebuilds and restarts your server. This is essential for web development in Go.

Delve: Debugging

Delve is Go's debugger. It understands goroutines, channels, and Go's runtime:

go install github.com/go-delve/delve/cmd/dlv@latest

Debug a Program

dlv debug ./cmd/myapp
(dlv) break main.go:42
(dlv) continue
(dlv) print myVar
(dlv) next
(dlv) step
(dlv) goroutines
(dlv) goroutine 1 bt  # backtrace of goroutine 1

Debug a Test

dlv test ./internal/store -- -test.run TestCreate

Attach to a Running Process

dlv attach <pid>

VS Code Integration

Delve integrates with VS Code through the Go extension. Set breakpoints in the editor and debug visually.

Key Delve commands:

Command      Description
----------------------------
break (b)    set breakpoint
continue (c) run until breakpoint
next (n)     step over
step (s)     step into
print (p)    print variable
locals       show local variables
goroutines   list goroutines
bt           backtrace

The CI Pipeline

A standard Go CI pipeline:

name: CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: stable

      - name: Vet
        run: go vet ./...

      - name: Lint
        uses: golangci/golangci-lint-action@v6

      - name: Test
        run: go test -race -coverprofile=coverage.out ./...

      - name: Build
        run: go build ./...

The order matters: vet and lint are fast and catch issues before expensive test runs.

Common Pitfalls

  • Not running go vet in CI. go vet has zero false positives. There is no reason to skip it.
  • Using too many linters. Start with the defaults in golangci-lint. Enable more only when you understand what they check.
  • Not committing generated files. If generated files are not in the repo, every developer needs the generation tools. Commit them and verify in CI.
  • Using fmt.Println for debugging instead of Delve. Print debugging works, but Delve lets you inspect state, goroutines, and channels interactively.
  • Skipping the -race flag in tests. go test -race finds data races. Run it in CI for every test.
  • Not using Air during development. Manual rebuild-restart cycles waste minutes every day. Live reload pays for itself immediately.

Key Takeaways

  • go vet catches real bugs with zero false positives. Run it always.
  • staticcheck catches what go vet misses. It is the most valuable single linter.
  • golangci-lint runs many linters in parallel. Configure it once, run in CI.
  • go generate automates code generation. Commit generated files and verify in CI.
  • Build tags separate integration tests, platform code, and feature flags.
  • -ldflags embeds version info at build time without modifying source.
  • air provides live reload for development. delve provides interactive debugging.
  • A CI pipeline should run: vet -> lint -> test -race -> build.