Getting started with Go
Installing Go and the Toolchain
Install Go, learn the go subcommands you will actually type, and pin a version so the toolchain that builds on your laptop is the one CI runs.
You push a branch. Every check passed on your machine. The pipeline fails on a line of code you did not touch, with a message about a function that does not exist or a construct the compiler refuses. The diff is not the problem. The Go on the build machine is a different Go from the one on yours.
That failure is cheap to prevent and slow to diagnose afterwards, because the error names your code rather than the toolchain. Installing Go takes a minute. Deciding which version the project uses, and making every machine honour that decision, takes a few more. Those minutes buy a build that behaves the same for you, for CI, and for the person who clones the repository in eight months.
The command you will type more than any other is go. Behind it sits a
build system, a dependency manager, a test runner and a formatter sharing one binary, so
the toolchain comes before any syntax.
The version you install becomes part of the build
Install a current release and note which one it is. Every example in this course
compiles on Go 1.24 or newer. The transcripts here come from go1.26.3 on
macOS arm64, and nothing in them needs a feature past that floor.
Any of the usual routes will do: Homebrew on macOS with brew install go,
the official tarball extracted into /usr/local on Linux, or the MSI
installer on Windows. Then check that your shell can find the binary.
$ go version
go version go1.26.3 darwin/arm64
That line carries three facts. The toolchain version, the operating system it targets, and the processor architecture. Two machines on the same version but different architectures are still running different compilers.
If the shell answers command not found, the Go binary directory is not
on PATH. On a Linux tarball install that directory is
/usr/local/go/bin. Adding it to your shell profile is the fix.
The go command is a build system, not a compiler wrapper
Eight subcommands cover most of a working day.
| Subcommand | What it does | When you reach for it |
|---|---|---|
go run |
Compiles into a temporary location, runs the result, deletes it. | Iterating on a program |
go build |
Compiles and leaves the binary on disk. | Release steps and container images |
go test |
Builds and runs tests, after a vet subset passes. | Every change |
go vet |
Reports suspicious constructs the compiler accepts. | Before you commit |
go fmt |
Runs gofmt over the named packages. | After editing by hand |
go mod |
Module maintenance: init, tidy, download, verify. |
Anything to do with dependencies |
go doc |
Prints documentation for a package or symbol. | Offline reference |
go env |
Prints the environment the go command is using. | Diagnosing a setup |
go run . compiles the package in the current directory, writes the
binary somewhere temporary, executes it and throws it away. Fast to iterate with,
useless for shipping. go build leaves the binary behind, which is what a
Dockerfile or a release step wants, and -o names the output.
go test ./... builds and runs tests for every package under the current
directory. The ./... is a recursive pattern; a bare . means
this package only. That distinction catches people the first time a new package's tests
appear not to run at all.
One behaviour is worth knowing before your first red build: go test
runs a high-confidence subset of go vet over the package and its test
files. A printf mistake or a malformed build tag fails the test run before a single
test executes. The subset is documented under go help test, and
-vet=off disables it. Turning off a check to make a red build green is a
decision worth defending out loud.
go doc fmt.Println prints the signature and doc comment without a
browser. go mod tidy adds the requirements you are missing and removes the
ones nothing imports, so run it before you commit a change to imports.
gofmt has one answer and no configuration
Go ships a formatter, and its output is treated as final. There is no line-length setting and no style preference to argue about: gofmt writes tabs for indentation, braces in one place, and struct fields aligned with their comments. Every Go file in every repository ends up laid out the same way, so a diff shows what changed rather than where the author happened to put a newline.
Editors run gofmt on save, and configuring that once is worth the five minutes.
$ gofmt -l .
main.go
gofmt -l lists files that differ from canonical form without touching
them, and -w rewrites them in place. A file can be perfectly correct and
still be listed here, because formatting and compilation are separate questions.
- total:=0
- for i:=1;i<=3;i++ {
- total+=i
- }
- fmt.Println(total)
+ total := 0
+ for i := 1; i <= 3; i++ {
+ total += i
+ }
+ fmt.Println(total)
The left side is spaces and no spacing around the operators. The right side is
what gofmt writes. Both compile and print the same number, which is why this needs a
tool rather than an eye. Put the check in CI as
test -z "$(gofmt -l .)": it runs in milliseconds, fails the build on any
unformatted file, and removes an entire category of review comment.
go vet reports problems the compiler is happy to accept
The compiler checks types and syntax. It does not check whether a format string matches the arguments you gave it. So this builds cleanly, and prints nonsense:
package main
import "fmt"
func main() {
fmt.Printf("%d items\n", "four")
}
go build ./... produces a binary. go vet ./... does
not:
$ go vet ./...
main.go:6:14: fmt.Printf format %d has arg "four" of wrong type string
Vet is a set of heuristics rather than a proof. It will not find a logic error, a
missing error check or a data race. What it does find, it finds quickly: the checks ship
with the toolchain, need no configuration, and go tool vet help lists the
whole set.
GOPATH still sets defaults, but your code does not live there
If a tutorial tells you to put your project in $GOPATH/src, it predates
modules. Before Go 1.11, a package's import path was its location under that directory,
and the toolchain had no other way to find your code. Modules replaced that, and they
have been the default since Go 1.16.
GOPATH did not disappear. It supplies two defaults: the module cache at
$GOPATH/pkg/mod, and the directory that go install writes
binaries into at $GOPATH/bin. On a machine where GOPATH is
still the default, those resolve like this:
$ go env GOPATH GOMODCACHE
/Users/ana/go
/Users/ana/go/pkg/mod
The module cache is content addressed and read-only. A dependency is stored once and
shared by every project that needs that version, keyed by a hash of its contents, which
is what lets go.sum detect a dependency that changed after you reviewed it.
go mod verify checks the cache against those hashes. go clean
-modcache empties it, and the next build re-downloads whatever it needs.
Do not put your own code in the cache, and do not edit what is in it. Both are easy to do by accident when you follow a stack trace into a dependency.
go env tells you what the shell is not telling you
Every setting the go command consults is readable as a variable. Run it bare for the full list, or name the ones you care about:
$ go env GOVERSION GOROOT GOFLAGS GOTOOLCHAIN GOOS GOARCH
go1.26.3
/usr/local/go
auto
darwin
arm64
GOROOT is where the toolchain is installed. GOMODCACHE is
the module cache from the previous section. The blank line is GOFLAGS,
which is empty by default and is where a build server might inject
-mod=readonly. GOTOOLCHAIN gets the next section to itself.
There is also go env -w, which persists a value to a per-user
configuration file rather than your shell. Use it for settings you want in every
session, and prefer your shell for anything that belongs to one project. Settings that
live in a file nobody else can see are a common source of "it works on my machine".
A version mismatch is the failure your diff cannot explain
Back to the opening problem. Suppose go.mod declares a version newer
than the one installed. The go command refuses, and the message names both sides of the
disagreement:
$ GOTOOLCHAIN=local go build ./...
go: go.mod requires go >= 1.99.0 (running go 1.26.3; GOTOOLCHAIN=local)
That is the good version of the problem, because it tells you the truth immediately. The bad version happens when the versions differ and nothing declares it. Your laptop has a newer Go, so a function added to the standard library last year compiles. CI has an older one, the symbol does not exist, and the error names your file and line. Same commit, different answer.
Two directives in go.mod control this. The go directive is
the minimum language version the module is written against:
module example.com/orders
go 1.24.0
The toolchain directive names a preferred toolchain, and by default
GOTOOLCHAIN=auto lets the go command download it. That convenience is also
how a mismatch hides: the build succeeds because the right compiler was fetched over the
network, and it fails the same way on a machine without network access. Setting
GOTOOLCHAIN=local in CI turns the download into a hard error, which is
usually the trade you want, because the failure then names a version instead of a
missing symbol.
Version floor
The toolchain directive and the GOTOOLCHAIN behaviour
described above are available in Go 1.24 and newer, the floor this course assumes.
Everything else in this lesson has been stable for considerably longer.
You can ask a binary which Go produced it, which is the fastest way to settle an argument about what actually shipped:
$ go version -m ./orders
./orders: go1.26.3
path example.com/orders/cmd/api
mod example.com/orders (devel)
Pin the version in the repository, read it from there in your build image, and let CI install exactly that. Pinning it in a wiki page does not survive the first person who upgrades their laptop.
Create a module, then make the toolchain disagree with itself on purpose.
mkdir -p /tmp/toolchain-lab
cd /tmp/toolchain-lab
go mod init example.com/toolchain-lab
Write a main.go that prints something. Then work through these in
order and keep the output of each one.
- Run
go env GOVERSION GOMODCACHE GOTOOLCHAINand write down all three values. - Misformat the file on purpose: spaces instead of tabs, no spaces around
=. Rungofmt -l ., note which file it names, then fix it withgofmt -w .. Confirmgo run .gave the same output both before and after. - Edit the
godirective ingo.modto a version above the one you have installed, runGOTOOLCHAIN=local go build ./..., and copy the error verbatim. - Put the directive back and confirm
go build ./...succeeds again.
Then answer one question: of the four things you did, which would a reviewer have caught and which only a machine could catch?
Show the solution and the reasoning
The version error is the interesting one, and it looks like this:
go: go.mod requires go >= 1.99.0 (running go 1.26.3; GOTOOLCHAIN=local)
Read it as an equation with both operands printed. The module asks for 1.99.0, the
machine has 1.26.3, and GOTOOLCHAIN=local is why the go command did not
go and fetch 1.99.0 for you. Remove that variable and the same command tries to
download a toolchain that does not exist, then fails with a different message about a
download. Both are errors, but only the first names the disagreement between the
project and the machine, which is why GOTOOLCHAIN=local belongs in
CI.
gofmt -l names the file, not a line, because formatting is a property
of the whole file: the tool reformats and then compares. The honest way to read the
result is "this file is not canonical", and the honest response is to run
gofmt -w rather than to hunt for an offending line. The
go run . check before and after is the point of the exercise: identical
output, different source, so arguing about formatting costs time and buys nothing.
The go env output describes your machine, not the project. That is
the distinction the last question is after. GOVERSION and
GOMODCACHE will differ between your laptop and a build container, and
neither is written down anywhere in the repository, which is why a version mismatch is
invisible in review. A reviewer reads the diff. The version CI runs is not in the
diff, and the author's machine is the one machine guaranteed to work.
The formatting slip is the one a reviewer would notice, since it shows up as whitespace noise in the diff. It is also the one nobody should have to notice.
Progress
Mark this lesson done and it stays marked in this browser.