GOROOT is where the Go toolchain itself lives: the compiler, the go command and the standard library. GOPATH (default ~/go) used to be the one workspace all your code had to sit in; with modules it only holds the download cache and installed binaries.
Since Go 1.16 module mode is the default: a project can live in any directory, and its go.mod, not its location, defines the import path.
$GOPATH/pkg/mod is the read-only cache of every downloaded module version, and $GOPATH/bin is where go install puts binaries unless GOBIN is set.
You almost never set GOROOT by hand. go env GOROOT GOPATH shows what the go command is actually using.
GoMiddle
Modules, Build & Tooling
go.mod and MVS, build tags, embed, ldflags, linters, static binaries
Questions closed
0 / 23
- 0Review
- 3Asked often
Questions
A package is a directory of .go files with the same package clause: the unit you import and the compiler builds. A module is a tree of packages with a go.mod at its root: the unit you version, tag and download.
An import path is the module path plus the directory: github.com/acme/billing/internal/order is package order inside module github.com/acme/billing.
Versions belong to modules, not packages. Tagging v1.4.0 releases every package in the tree at once.
A repository usually holds one module. A nested go.mod cuts its subtree off into a separate module with its own versions.
go.mod decides: it declares the module path, the Go version and a minimum version for each dependency. go.sum is not a lock file but a list of checksums that the downloaded code for those versions must match.
go.sum holds two hashes per version: one for the module's source zip, one for its go.mod file alone.
Every download is checked against it before compiling, so a tampered or re-tagged release fails the build instead of shipping. The first time anyone fetches a version, the public checksum database sum.golang.org vouches for its hash.
Commit both files. A merge conflict in go.sum is resolved with go mod tidy, not by deleting the file.
go mod tidy makes go.mod and go.sum match your imports: it adds missing requirements and removes unused ones. go get changes a requirement and re-runs version selection, and go mod verify checks that the module cache was not modified.
**go mod tidy** looks at every package and test in the module, for all platforms and build tags, so a dependency used only in a _test.go or _windows.go file stays. It never upgrades what is already required. In CI, go mod tidy -diff (Go 1.23+) fails when someone forgot it, without rewriting files.
**go get pkg@v1.5.0** raises or lowers that requirement, adjusts whatever else the new version needs, and writes the go.sum lines. A hand edit does none of that, and since Go 1.16 builds run with -mod=readonly: they fail with updates to go.mod needed instead of fixing the file quietly.
**go mod verify** re-hashes the module cache and prints all modules verified if nothing changed since download. It guards against local tampering; go.sum guards the download.
// indirect marks a module that none of your own packages import: it is in go.mod because a dependency needs it, or because you raised its version on purpose.
Since go 1.17 the main go.mod lists every module that provides a package to your build, not only the ones you import. That is bookkeeping, not new dependencies.
With the full list written down, the go command can skip reading most dependencies' own go.mod files (module graph pruning), so it loads less.
go mod tidy keeps indirect requirements in a separate require block and adds or removes the marker itself; there is no reason to edit it by hand.
Raising an indirect version is normal: go get golang.org/x/crypto@v0.35.0 takes a security fix without waiting for the library in between.
Use go install <package>@<version>. Since Go 1.18 go get only manages requirements in go.mod; building and installing commands moved to go install.
With a version suffix, go install go.uber.org/mock/mockgen@v0.6.0 ignores the go.mod in your directory, so installing a tool cannot change your dependencies.
The binary lands in GOBIN, or in $(go env GOPATH)/bin (~/go/bin) when that is unset. That directory has to be on PATH.
Pin the version for anything that generates code: with @latest, two machines can generate different files.
To pin a tool for the whole team, see go get -tool (Go 1.24+).
go generate runs the commands written in //go:generate comments, in each file's directory, to write Go source such as mocks, String methods or sqlc queries. Builds never run it, so the output is committed with the code like any other source.
Why builds never run it: a build must work from the module alone. Whoever runs go get or go install pkg@version has no mockgen or protoc, and a build that ran commands from a dependency would be a security hole.
Commit the output: then the module builds anywhere, and review shows what the generator changed. Generated files start with a line matching ^// Code generated .* DO NOT EDIT\.$, which linters use to skip them and which tells people not to edit them by hand.
Keep it current in CI: run go generate ./..., then git diff --exit-code, which fails when someone changed the source and forgot to regenerate.
Details: no space after //; no shell, so no pipes or globs; $GOFILE and $GOPACKAGE are set; -run stringer runs only matching directives. Call the generator through go tool, pinned with go get -tool, so every machine generates the same bytes.
gofmt fixes layout, go vet finds code that compiles but is almost certainly wrong, staticcheck adds deeper bug and simplification checks, and golangci-lint runs those and many more in one pass with one config.
**gofmt / goimports**: one canonical format, so reviews never argue about style. CI fails if gofmt -l . prints any file (or runs golangci-lint fmt --diff).
**go vet** ships with Go: Printf verbs that do not match their argument, a discarded cancel from context.WithTimeout, a copied sync.Mutex. go test already runs a high-confidence subset and refuses to run the tests when it fires.
**staticcheck: calls to deprecated APIs, possible nil dereferences, values assigned and never read, code that can be simplified.
golangci-lint** is a runner. v2 enables govet, errcheck, staticcheck, ineffassign and unused by default; teams often add gosec, errorlint, bodyclose and revive. Pin its version in CI like any dependency; on a legacy codebase, --new-from-rev=origin/main makes only new code fail the check.
A doc comment is a plain // comment directly above an exported declaration, starting with the name it describes. go doc and pkg.go.dev render it, so there is no separate documentation format to maintain.
The package comment goes above the package clause in one file (often doc.go) and starts with Package billing .... go doc billing.Charge prints one symbol, go doc -all ./billing the whole package.
Light syntax is allowed: blank-line paragraphs, indented code blocks, [ErrDeclined] links to other symbols, # headings and - lists. gofmt reformats doc comments into the canonical form.
A paragraph that starts with Deprecated: marks an API as retired, and tools such as staticcheck then flag its callers.
v0.31.0. Go uses Minimal Version Selection: every require line is a minimum, and the build takes the highest minimum anyone states, never a newer release just because it exists. The same go.mod files always give the same answer, so no lock file is needed.
npm or Cargo resolve version ranges against what is published today, so they need a lock file to freeze the result. MVS reads only the go.mod files in the graph, so the same commit selects the same versions years later.
go get github.com/acme/kit@v1.4.0 moves x/net only if kit v1.4.0's own go.mod asks for more than v0.31.0.
Upgrades are always explicit: go get -u ./... takes the newest minor and patch versions, -u=patch stays on the current minor lines, and go get golang.org/x/net@v0.45.0 raises one floor.
go list -m all prints the selected build list; go list -m -u all shows what could be upgraded without changing anything.
This is semantic import versioning: from v2 on, the major version is part of the module path (module github.com/acme/lib/v2) and of every import, because a breaking release is treated as a different module. Tag v2.0.0 without it and nobody can use that tag.
github.com/acme/lib and github.com/acme/lib/v2 are separate modules. go get -u on the v1 path never moves to v2, and a pinned @v2.0.0 fails with module contains a go.mod file, so module path must match major version ("github.com/acme/lib/v2").
To release v2: change the module line to end in /v2, update the module's own imports of its packages, then tag.
v0 promises nothing and v1 promises compatibility. That is why v0 and v1 need no suffix, and why any breaking change after v1 needs a new path.
v1 and v2 can be imported side by side during a migration, but their types are unrelated: a lib.Client cannot be passed where a libv2.Client is expected.
Repositories that were tagged past v1 before modules existed show up as v2.1.0+incompatible.
replace and exclude apply only in the main module, the one being built. When a service builds, your library's go.mod contributes its require lines and nothing else, so the rewrite rule is ignored.
Requirements are inherited, so raise the floor instead: require github.com/them/thing v1.4.2. Every consumer then builds at least that version.
If upstream is abandoned, publish the fork under its own module path and import that path. Then it is a real dependency, not a rewrite rule.
replace belongs in the module you build: forcing a patched version in a service, or pointing at a local checkout with replace example.com/lib => ../lib (for local multi-module work go.work is usually the better tool).
A published module that contains replace cannot be installed with go install pkg@version either: the go command refuses it, because the module would build differently as a dependency than on its own.
Set GOPRIVATE to your private path prefixes, for example git.corp.example/*. It tells the go command to fetch those paths directly from Git instead of the public proxy, and never to look them up in the public checksum database.
Without it, the default GOPROXY=https://proxy.golang.org,direct asks the public mirror first, and the lookup in sum.golang.org fails with errors like 410 Gone.
GOPRIVATE sets both GONOPROXY and GONOSUMDB. Set those two separately only when a company proxy such as Athens or Artifactory should serve private modules: GOPROXY=https://goproxy.corp.example,direct.
Authentication is plain Git: an SSH key with an insteadOf rewrite, or a token in ~/.netrc on CI.
go.sum still applies: private modules are checked against the hashes you committed.
Vendoring (go mod vendor, commit vendor/) puts every dependency's source into the repository. While vendor/ exists, builds use it and need no network or credentials. Use it for air-gapped builds or when security wants dependency diffs in review; the price is a bigger repository and re-running go mod vendor after every dependency change.
The go line sets the language version the module's code is compiled with and, since Go 1.21, the minimum Go release allowed to build it. If the installed Go is older, by default it downloads the required toolchain and re-runs itself.
Language rules follow the go line, not the installed compiler: with go 1.21, even Go 1.25 rejects for i := range 3, and the Go 1.22 per-iteration loop variable stays off for that module.
The toolchain line names a preferred newer toolchain. It counts only when this module is the main module; in dependencies it is ignored.
GOTOOLCHAIN=auto (the default) allows the download. GOTOOLCHAIN=local forbids it, and an old Go fails with go.mod requires go >= 1.24.0 (running go 1.23.6; GOTOOLCHAIN=local). CI images often set local so the artifact is built by exactly the Go in the image.
Adding a dependency whose go line is newer raises yours. Change both lines with go get go@1.24.0 and go get toolchain@go1.25.3, not by hand.
Since Go 1.24, use the tool directive: go get -tool pkg@version records the tool in go.mod, and go tool <name> runs exactly that version. Before 1.24 the same pinning was done with a tools.go file of blank imports behind a build tag.
**tool directive**: the version lives in go.mod and go.sum like any dependency, go tool mockgen needs no PATH setup, and the built binary is cached. go install tool installs every listed tool, go get tool upgrades them all.
**tools.go**: //go:build tools plus import _ "go.uber.org/mock/mockgen", run with go run go.uber.org/mock/mockgen. It works, but it is a convention the go command knows nothing about.
The cost of both: the tool's dependencies join your module graph. mockgen v0.6.0 brings in golang.org/x/tools v0.36.0 (plus x/mod and x/sync), and MVS may raise a library your own code uses. When that matters, keep tools in a separate module file: go get -tool -modfile=tools/go.mod ... and go tool -modfile=tools/go.mod mockgen.
Some tools, golangci-lint among them, recommend installing their release binary instead of building it from your module.
Keep it flat and grouped by domain: one cmd/<binary> directory per executable with a thin main, and the rest of the code under internal/. The go command forbids importing an internal package from outside the tree rooted at its parent directory.
internal is enforced, not a naming convention: an import from another module fails with use of internal package ... not allowed. That lets you change those APIs without breaking anyone.
Name packages after what they provide (order, payment, postgres), not after layers like models, utils or helpers that every package ends up importing, which is the usual source of import cycles.
cmd/api/main.go reads config and wires dependencies together. Logic left in main cannot be imported, so it cannot be tested or reused.
pkg/ is optional and many teams skip it. The popular golang-standards/project-layout repository is not an official standard; the Go team's own guidance is go.dev/doc/modules/layout.
Start small: a single package is fine until it starts to hurt.
Go compiles and initialises packages one at a time in dependency order, and a cycle leaves no valid order, so import cycle not allowed is a build error. Break it by making imports point one way, most often with an interface declared by the package that needs it.
If store must call service, let store declare a small interface with the one method it needs. service satisfies it without being imported, because Go interfaces are satisfied implicitly, and main wires the two together.
Or move the shared types into a third, lower-level package that both import.
Or merge the two packages: a cycle often means the split was never real.
The rule keeps the package graph acyclic, which is also what lets Go compile packages in parallel and cache each one separately.
A build constraint decides whether a file is part of the build for the current GOOS, GOARCH, Go version and -tags. Cross-compiling is just naming the target, GOOS=linux GOARCH=arm64 go build, with no extra toolchain as long as cgo is off.
//go:build linux && (amd64 || arm64) goes above the package clause. File-name suffixes such as _windows.go or _linux_arm64.go are implicit constraints, and _test.go files are built only by go test.
Custom tags select optional code: //go:build integration plus go test -tags integration ./....
A file excluded by its constraint is invisible to the compiler, go vet and gopls. Code behind a tag nobody passes can stop compiling unnoticed, so CI should build and vet with every tag set you ship, e.g. go vet -tags integration ./....
cgo is off by default when cross-compiling. Code that needs cgo needs a C cross-compiler for the target (set CC), which is one reason most services build with CGO_ENABLED=0.
go tool dist list prints every supported GOOS/GOARCH pair.
It prints 1.4.0 none 2026-10-14 "". -X only overwrites package-level string variables that are uninitialised or initialised to a constant, and it stays silent whenever it cannot apply.
version and buildTime are set: one has a constant initialiser, the other has none.
commit is a constant. Constants are folded in at compile time, so there is no variable for the linker to write, and the flag is ignored.
env is written by the linker, then overwritten at start-up when its initialiser os.Getenv runs. Any initialiser that calls a function or reads another variable defeats -X.
A typo is just as silent: -X main.Version=... with a capital V leaves version at "dev". Catch it by running ./app --version in CI.
Outside main, use the full import path: -X github.com/acme/app/internal/build.Version=1.4.0.
govulncheck reports a vulnerability only when your code can actually reach the vulnerable function, using the call graph and the Go vulnerability database.
Version-matching scanners flag every vulnerable module version in go.sum, including ones whose affected function you never call. govulncheck ./... lists those separately as present but not called and fails the run only for reachable ones, so CI can gate on it.
It also checks the standard library against your Go version. A vulnerability in net/http or crypto/tls is fixed by a toolchain upgrade, not by go get.
govulncheck -mode=binary ./app scans a built binary, such as the one actually deployed.
To fix a reachable finding, raise the floor yourself, e.g. go get golang.org/x/net@v0.38.0, without waiting for the library in between.
To see why a flagged module is in the build at all, go mod why -m <module> prints the import chain; it answers (main module does not need module ...) when no package imports it, so it is in go.mod only for the version graph. go mod graph | grep x/crypto@ shows which dependency asks for which version.
go.work changes the module graph on the machine that has it: its use lines make the listed directories main modules, so laptops resolve example.com/acme/lib from the sibling directory. CI has no go.work, reads only api/go.mod, and finds no require for lib.
go work init ./api ./lib writes those use lines. Every go command below that directory then prefers the local copies, and go env GOWORK names the file in effect.
Reproduce CI before pushing a cross-module change: GOWORK=off go build ./... and GOWORK=off go test ./... catch the require line nobody added.
Land it in order: tag lib (in a monorepo the tag is prefixed with the directory: lib/v0.4.0), then go get example.com/acme/lib@v0.4.0 in api. The workspace covers the time before the tag; it does not replace the tag.
A workspace beats committed replace ../lib lines, which change the build for everyone and block go install.
Commit go.work in a monorepo where all modules are always built together from one commit; keep it out of git when modules are released and consumed as versions.
A Go build is deterministic given the same source, dependencies, toolchain, flags and environment. In practice the differences come from absolute file paths (fixed with -trimpath), a different Go release, cgo pulling in the host's C toolchain, or a flag that differs per build.
Sources of difference
Without -trimpath, the checkout path (/home/runner/work/...) is embedded for stack traces, so runners with different workspaces produce different bytes.
Different Go patch releases generate different code. Pin it with the toolchain line or GOTOOLCHAIN=go1.25.3; the Go toolchain itself is reproducible since Go 1.21.
go.sum already pins the dependency bytes. CGO_ENABLED=0 takes the host C compiler and libc out of the picture.
Keep GOFLAGS, GOAMD64/GOARM64 and -ldflags identical. A build timestamp passed with -X makes every build unique by design; use the commit time instead.
What the binary records
go version -m ./app prints the Go version, every dependency with its hash, the build flags, CGO_ENABLED, vcs.revision, vcs.time and vcs.modified.
debug.ReadBuildInfo() reads the same data at runtime, so a service can report its commit without any -X.
vcs.modified=true, or a +dirty main-module version in Go 1.24+, means the tree had uncommitted changes, so nobody can rebuild that artifact from git.
PGO feeds a CPU profile from production into the compiler, which then optimises the code that is actually hot. Put the profile at default.pgo next to the main package and, since Go 1.21, go build uses it by default; typical gains are a few percent of CPU.
What it optimises
Inlining: hot call sites get a much larger inlining budget than the default heuristics allow.
Devirtualisation: a hot interface call whose profile shows one dominant concrete type becomes a direct call behind a type check, which can then be inlined too.
The Go release notes report roughly 2–14% CPU improvement depending on the program, plus some growth in build time and binary size.
How to roll it out
Collect a representative CPU profile from production via net/http/pprof (/debug/pprof/profile?seconds=30), from several instances, and merge them with go tool pprof -proto.
Commit it as cmd/api/default.pgo. The default -pgo=auto picks it up for that main package and applies it to every package the binary contains.
Refresh it now and then. Stale profiles degrade gracefully: changed code just loses the optimisation, so building each release with the previous release's profile is the normal workflow.
Prove the win with production CPU metrics or benchstat, and avoid profiles from microbenchmarks, which optimise the wrong paths.