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.
Related Resources
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.