Skip to content
System nominalUTC --:--:--Articles 041
MODULE 01 Lesson 01 / 09 25 MIN

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.

SubcommandWhat it doesWhen 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.

Lab 20 minutes

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.

  1. Run go env GOVERSION GOMODCACHE GOTOOLCHAIN and write down all three values.
  2. Misformat the file on purpose: spaces instead of tabs, no spaces around =. Run gofmt -l ., note which file it names, then fix it with gofmt -w .. Confirm go run . gave the same output both before and after.
  3. Edit the go directive in go.mod to a version above the one you have installed, run GOTOOLCHAIN=local go build ./..., and copy the error verbatim.
  4. 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.