62. Modules, formatting and the `go` tool
Go's tooling is one binary with subcommands, and it covers dependency management, formatting, static analysis, building and testing. There's no build file to write and no formatter to configure — which is the point.
Starting a module
$ mkdir myapp && cd myapp
$ go mod init github.com/you/myapp
go: creating new go.mod: module github.com/you/myapp
That writes go.mod:
module github.com/you/myapp
go 1.26.5
The module path is how the rest of the world imports your code. Use the repo
URL even for private projects — it costs nothing and saves a rename later.
The go line records the minimum language version the module needs, and
go mod init fills in whatever toolchain you ran it with — yours will show a
different number than this one, which is expected.
Adding dependencies
$ go get github.com/google/uuid
go: downloading github.com/google/uuid v1.6.0
go: added github.com/google/uuid v1.6.0
$ go mod tidy # add what's imported, remove what isn't
$ go mod download # fetch everything into the module cache
After that, go.mod lists your requirements and go.sum records a
cryptographic hash of every module version you use.
Commit both files. They do different jobs, and it's worth keeping them
straight: go.mod decides which versions you build against, and go.sum
records a cryptographic hash of each one so you can prove you got the same
bytes. Together they make a build reproducible and tamper-evident — if a
published version ever changes content, the build fails loudly instead of
silently running different code.
go mod tidy is the one to run habitually: it syncs go.mod with what your
code actually imports. Run it before every commit that touches imports.
Semantic import versioning
require (
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
)
Go modules follow semver, with one distinctive rule: v2 and above put the major version in the import path.
import "github.com/foo/bar" // v0 or v1
import "github.com/foo/bar/v2" // v2
It looks strange and it's deliberate: v1 and v2 of the same library are different import paths, so they can coexist in one build. That's how Go avoids the diamond-dependency deadlock where two of your dependencies need incompatible versions of a third.
Upgrading:
$ go get -u ./... # upgrade to latest minor/patch
$ go get github.com/foo/bar@v1.5.0 # a specific version
$ go list -m -u all # what's out of date
Package layout
myapp/
├── go.mod
├── go.sum
├── main.go // package main — the entry point
├── internal/ // importable ONLY within this module
│ └── store/
│ └── store.go // package store
├── pkg/ // (optional) public library code
│ └── client/
│ └── client.go
└── cmd/ // several binaries in one module
├── server/main.go
└── worker/main.go
Two rules the tooling actually enforces:
- One package per directory. The directory name and the package name should match.
internal/is special. Anything under aninternal/directory can only be imported by code in the same module. It's compiler-enforced privacy for whole packages, and it's the right default for code you don't want to support as an API.
cmd/ is a convention, not a rule, for modules that build more than one
binary.
Exported means capitalised
package main
import "fmt"
type Server struct {
Host string // exported — visible to other packages
port int // unexported — this package only
}
func (s *Server) Address() string { return fmt.Sprintf("%s:%d", s.Host, s.port) }
func (s *Server) setPort(p int) { s.port = p }
func main() {
s := &Server{Host: "localhost"}
s.setPort(8080)
fmt.Println(s.Address())
}
Capital first letter = exported. That's the entire visibility system — no
public, private or protected keywords. It applies to types, functions,
methods, fields and constants alike.
Export deliberately: everything capitalised is a promise you have to keep.
gofmt — the argument that never happens
$ gofmt -l . # list files that need formatting
$ gofmt -w . # rewrite them
$ go fmt ./... # same thing, module-aware
Tabs for indentation, specific brace placement, aligned struct fields, grouped imports. There are no options. Every Go codebase on earth looks the same, so you can read any of them, and code review never spends a second on style.
Set your editor to run it on save and forget it exists.
goimports is the community superset that also adds and removes import
lines as you edit — most Go setups use it instead.
go vet — the bugs the compiler allows
$ go vet ./...
./main.go:12:2: fmt.Printf format %d has arg name of wrong type string
./main.go:20:2: lock passed by value: sync.Mutex contains sync.noCopy
./main.go:31:2: the cancel function is not used on all paths (possible context leak)
vet catches a specific list of things that compile but are almost certainly
wrong — and every item on it is a mistake this course has warned you about:
Printfformat/argument mismatches (module 11)- copying a struct containing a mutex (module 8)
- an unused
contextcancel function (module 9) - unreachable code, and suspicious struct tags (module 11)
- loop-variable capture problems in older Go versions (module 3)
go test runs a subset of vet automatically. Run the full go vet ./... in
CI.
For more, staticcheck is the standard third-party linter — it finds
redundant code, misuse of the standard library, and a long tail of real bugs.
golangci-lint bundles it with many others behind one config.
Building and running
$ go run . # compile to a temp dir and run
$ go build # produce ./myapp
$ go build -o bin/server ./cmd/server
$ go install ./cmd/server # build into $GOPATH/bin
# cross-compile — no toolchain to install
$ GOOS=linux GOARCH=amd64 go build -o server-linux
$ GOOS=darwin GOARCH=arm64 go build -o server-mac
$ GOOS=windows GOARCH=amd64 go build -o server.exe
# smaller binaries: strip debug info
$ go build -ldflags="-s -w"
# stamp a version into the binary at build time
$ go build -ldflags="-X main.version=1.2.3"
Cross-compiling by setting two environment variables is genuinely one of Go's best features. The output is a single static binary with no runtime to install — which is why Go took over containers and CLI tools.
That -X main.version=1.2.3 sets a package-level string variable at link
time; var version = "dev" in your main package picks it up.
Documentation
package main
import "fmt"
// Sum returns the total of all the numbers.
//
// It returns 0 for an empty slice. The zero value is meaningful here, so
// callers do not need to check the length first.
func Sum(nums []int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
func main() {
fmt.Println(Sum([]int{1, 2, 3}))
fmt.Println(Sum(nil))
}
Doc comments are ordinary comments directly above a declaration, starting
with the name being documented. go doc ./... reads them, and
pkg.go.dev renders them for any public module automatically.
The convention — "Sum returns..." rather than "This function returns..." — is so consistent across Go that breaking it looks wrong.
The commands worth memorising
go mod init <path> start a module
go mod tidy sync dependencies with imports
go get <pkg> add or upgrade a dependency
go build compile
go run . compile and run
go test ./... run tests
go test -race ./... run tests with the race detector
go test -bench=. run benchmarks
go fmt ./... format
go vet ./... static analysis
go doc <pkg> read documentation
go clean -modcache nuke the module cache when something is wrong
Your turn
Add a doc comment in the proper Go style and make Version exported while
buildID stays package-private:
app v1.2.3 (build 42)
package main
import "fmt"
// add a doc comment for Version below, in Go style
var Version = "1.2.3"
var buildID = 42
func main() {
fmt.Printf("app v%s (build %d)\n", Version, buildID)
}
package main
import "fmt"
// Version is the semantic version of this binary. It is overridden at build
// time with -ldflags="-X main.Version=...".
var Version = "1.2.3"
// buildID is an internal counter and is deliberately not exported.
var buildID = 42
func main() {
fmt.Printf("app v%s (build %d)\n", Version, buildID)
}
You now know the language and the tools around it. The last module is three projects that use all of it.