Standard Library & Tooling

Difficulty

Modules replaced the old GOPATH-based dependency model, and go.mod/go.sum are the two files that make a module's dependencies explicit and verifiable.

A typical go.mod

module github.com/you/myapp

go 1.22

require (
    github.com/gin-gonic/gin v1.9.1
    golang.org/x/sync v0.5.0
)

module declares the module's own import path. go 1.22 declares the minimum language version the code requires. require lists direct dependencies and their exact resolved versions.

A snippet of go.sum

github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=

Each line pins a cryptographic hash for a specific module version's content, and for its go.mod file separately. go build verifies every downloaded module against these hashes, refusing to proceed if anything doesn't match.

Why both files are needed

go.mod alone tells you which versions you want. Without go.sum, nothing verifies that what actually gets downloaded from a proxy, a cache, or a git host is really the same bytes you originally built and tested against. go.sum closes that gap. It protects against a compromised registry or a mutated tag silently swapping in different code under the same version number.

Common commands

go mod init github.com/you/myapp   # create a new go.mod
go mod tidy                        # add missing / remove unused dependencies, update go.sum
go mod download                    # fetch dependencies into the local module cache
go mod verify                      # check local cache against go.sum

Related Resources

go vet and a linter both analyze code without running it, but they aim at different problems.

A bug go vet catches

func main() {
    name := "Alice"
    fmt.Printf("Hello, %d\n", name)  // %d expects an integer, got a string
}
go vet ./...
./main.go:4:2: Printf format %d has arg name of wrong type string

This is a real bug (it would print garbage, not crash, at runtime), and go vet catches it at build time without ever running the program.

Another example: copying a Mutex

type Counter struct {
    mu sync.Mutex
    n  int
}

func increment(c Counter) {  // BUG: value receiver copies the Mutex
    c.mu.Lock()
    c.n++
    c.mu.Unlock()
}
./main.go:8:16: increment passes lock by value: main.Counter contains sync.Mutex

What a linter adds on top

golangci-lint run
main.go:12:2: unused variable 'x' (unused)
main.go:20:1: cyclomatic complexity 15 of function process() is high (gocyclo)
main.go:5:1: exported function Foo should have comment (golint)

These are real code-quality issues, but none of them are "this is definitely a bug" the way a go vet finding usually is — they're closer to style and maintainability guidance, and often configurable per team (some teams disable comment-requirement checks, for instance).

Practical guidance

Run go vet unconditionally in CI. It's fast, has essentially no false positives, and every finding usually indicates a genuine bug worth fixing immediately. Add a linter like golangci-lint (which typically wraps go vet plus many other analyzers) on top for broader code-quality enforcement, with rules tuned to your team's preferences.

Related Resources

Go's stance on formatting is unusually opinionated compared to most languages: there's exactly one accepted style, and the tooling enforces it automatically.

Running gofmt

gofmt -l .        # list files that aren't formatted correctly
gofmt -w file.go  # rewrite file.go in place to the canonical style
// Before
func   add(a,b int)int{
return a+b
}

// After gofmt
func add(a, b int) int {
	return a + b
}

goimports: gofmt plus import management

goimports -w file.go
// Before: fmt is used, but not imported; os is imported, but never used
import (
    "os"
)

func main() {
    fmt.Println("hi")
}

// After goimports
import (
    "fmt"
)

func main() {
    fmt.Println("hi")
}

goimports adds the missing "fmt" import automatically and drops the unused "os" import, saving a step most other languages' tooling doesn't automate as smoothly.

Why zero configuration is a deliberate choice

Most formatters (Prettier, Black, clang-format) support configuration files with dozens of options: tab width, brace placement, line length, and more. gofmt has essentially none of that. Every .go file, from any project, formats identically. This eliminates formatting-based bikeshedding in code review entirely, and means switching between codebases never requires relearning a different house style.

Practical integration

Most editors run gofmt/goimports automatically on save. Many teams add a CI check (gofmt -l . should output nothing) to guarantee no unformatted code merges, treating formatting as a build requirement rather than a style suggestion.

Related Resources

Cross-compiling a Go program is often just setting two environment variables, no extra tools required.

Basic cross-compilation

GOOS=linux GOARCH=amd64 go build -o myapp-linux-amd64 .
GOOS=darwin GOARCH=arm64 go build -o myapp-mac-arm64 .
GOOS=windows GOARCH=amd64 go build -o myapp-windows-amd64.exe .

Each of these produces a native, statically-linked-by-default binary for its target platform, buildable from any single development machine.

Listing supported combinations

go tool dist list
darwin/amd64
darwin/arm64
linux/386
linux/amd64
linux/arm64
windows/amd64
...dozens more...

Why this is simpler than in C/C++

C/C++ cross-compilation typically needs:
  - A separate cross-compiler toolchain per target (e.g., a Linux-hosted
    compiler that targets ARM)
  - Target-specific system headers and libraries installed
  - Careful handling of dynamic linking against the target's C library

Go cross-compilation needs:
  - GOOS and GOARCH set
  - That's it, in the common case

Go's standard library ships pre-compiled for every supported platform combination, and Go binaries are statically linked by default (aside from cgo-using code), so there's no target-side system library dependency to manage in the typical case.

The cgo caveat

CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build .

If your code uses cgo (calling into C libraries), cross-compilation gets much harder, since it genuinely needs a C cross-compiler for the target platform. Setting CGO_ENABLED=0 disables cgo entirely, forcing a pure-Go build, which is why minimal container images for Go services often explicitly set this to guarantee simple, dependency-free cross-compilation.

Build tags let a single codebase compile differently depending on the target platform or an explicit flag, without runtime branching.

Explicit build tags

//go:build linux

package syscall_wrapper

func platformSpecificCall() { /* Linux-specific implementation */ }
//go:build !linux

package syscall_wrapper

func platformSpecificCall() { /* fallback for everything else */ }

Only one of these two files compiles into the final binary, depending on GOOS, with the compiler choosing automatically. There's no if runtime.GOOS == "linux" at runtime; the exclusion happens entirely at compile time.

Filename-based constraints (implicit, no comment needed)

worker_windows.go   // only compiled when GOOS=windows
worker_linux.go     // only compiled when GOOS=linux
worker_test.go      // only compiled when running `go test`

Go recognizes _GOOS.go, _GOARCH.go, and _test.go suffixes automatically, as an alternative to writing an explicit //go:build line.

Custom tags for optional features or test categories

//go:build integration

package myapp_test

func TestDatabaseIntegration(t *testing.T) { /* needs a real database */ }
go test ./...                       # skips this file entirely
go test -tags=integration ./...     # includes it

This is a common pattern for keeping slow, environment-dependent integration tests out of the default go test ./... run used during everyday development, while still keeping them in the same repository and easily runnable in CI with the right flag.

The practical guidance

Reach for build tags specifically when code genuinely needs to differ per platform or build mode, not as a general-purpose feature-flagging mechanism. For runtime-configurable behavior, a normal if branch or config value is almost always the simpler, better choice.

Related Resources