Learn Go
Go (often called Golang) is a statically typed, compiled language created at Google by Robert Griesemer, Rob Pike, and Ken Thompson, first released in 2009 and at version 1.26 as of February 2026. It was designed for simplicity, fast compilation, and concurrency: a small, readable syntax; a powerful standard library; built-in tooling (go build, go test, gofmt); and lightweight goroutines and channels that make concurrent programs easy to write. This track teaches Go from the ground up - installing the toolchain, the type system, functions and interfaces, slices and maps, idiomatic error handling, concurrency, and the module-based ecosystem - with runnable examples and links to the official docs at go.dev.
Setup and the Go Toolchain
Install Go, run your first program, and learn the core commands.
Setup and the Go Toolchain
Go ships as a single toolchain: one binary, go, drives everything - building, running, testing, formatting, and dependency management. There is no separate build system to learn and no package.json equivalent to hand-edit. This lesson gets you from zero to a running program.
Installing Go
Download the installer for your platform from the official site, or use a package manager. After installing, confirm the version:
$ go version
go version go1.26.0 linux/amd64
As of February 2026 the current release is Go 1.26. Go follows a roughly six-month release cadence and keeps a strong backward-compatibility promise (the "Go 1 compatibility guarantee"), so code written years ago still compiles today.
Your first program
Create a folder and a file named hello.go:
package main
import "fmt"
func main() {
fmt.Println("Hello, Go!")
}
Every Go file begins with a package declaration. An executable program lives in package main and must define a func main(). The import brings in the fmt (format) package from the standard library.
Run it directly without a separate compile step:
$ go run hello.go
Hello, Go!
go run compiles to a temporary binary and executes it - perfect for quick iteration. To produce a real binary you ship, use go build:
$ go build -o hello hello.go
$ ./hello
Hello, Go!
Go cross-compiles trivially. To build a Linux ARM64 binary from a Mac, set two environment variables:
$ GOOS=linux GOARCH=arm64 go build -o hello-linux hello.go
No C toolchain, no cross-compiler setup - the Go distribution includes everything.
Modules: the unit of a project
Modern Go organizes code into modules. A module is a directory tree with a go.mod file at its root that records the module path and dependencies. Initialize one with go mod init:
$ go mod init example.com/hello
go: creating new go.mod: module example.com/hello
This writes a go.mod:
module example.com/hello
go 1.26
Once you have a module you can run go run . (the dot means "the package in this directory") and add dependencies with go get. We cover modules in depth in the ecosystem lesson; for now, just know that go mod init is the first command in any new project.
The commands you will use daily
| Command | What it does |
|---|---|
go run . |
Compile and run the current package |
go build |
Compile to a binary |
go test ./... |
Run all tests in the module |
go fmt ./... |
Reformat code to the canonical style |
go vet ./... |
Report likely mistakes |
go doc fmt.Println |
Show documentation for a symbol |
The ./... pattern means "this directory and everything beneath it."
gofmt: there is one true style
Go intentionally has no style debates. gofmt rewrites your code into the canonical layout - tabs for indentation, fixed brace placement, aligned struct fields. Most editors run it on save. Because everyone's code looks the same, diffs are clean and reading unfamiliar code is easier. Run it yourself any time:
$ gofmt -w hello.go
Workspace layout
A typical small project looks like this:
myapp/
go.mod
main.go
internal/
store/
store.go
store_test.go
Packages map to directories: every .go file in a directory must declare the same package name. The special internal/ directory restricts imports to the surrounding module, a built-in encapsulation mechanism.
Where to go next
The official tutorial walks through the same steps interactively, and the Tour of Go is an in-browser playground that requires no installation at all.
- Official getting-started tutorial: https://go.dev/doc/tutorial/getting-started
- A Tour of Go (interactive): https://go.dev/tour/
With the toolchain working you are ready to learn the language itself, starting with its types.
Syntax, Variables, and the Type System
Declarations, basic types, structs, and control flow.
Syntax, Variables, and the Type System
Go is statically typed but rarely feels verbose, thanks to type inference and a small, orthogonal set of constructs. This lesson covers declarations, the built-in types, structs, and control flow.
Declaring variables
There are two main forms. The explicit var form, and the short declaration := that infers the type:
var name string = "Ada"
var count int // zero value: 0
age := 37 // inferred as int
pi := 3.14159 // inferred as float64
A crucial Go rule: every variable has a zero value. Numbers are 0, strings are "", booleans are false, and pointers, slices, maps, and interfaces are nil. There is no "uninitialized garbage." The short form := only works inside functions; at package level you must use var.
Go is strict about unused things: an imported package or a declared local variable that is never used is a compile error, not a warning. This keeps code tidy but surprises newcomers.
Constants and iota
Constants are declared with const and can use iota to build enumerations:
const Pi = 3.14159
type Weekday int
const (
Sunday Weekday = iota // 0
Monday // 1
Tuesday // 2
)
iota starts at 0 in each const block and increments by one per line.
Basic types
- Integers:
int,int8/int16/int32/int64, and unsigneduintvariants.intis 64-bit on modern platforms. - Floats:
float32,float64. bool,string(UTF-8 encoded, immutable).byte(alias foruint8) andrune(alias forint32, a Unicode code point).
Go does no implicit numeric conversion. You must convert explicitly:
var x int = 10
var y float64 = float64(x) // required; `float64(x)` not just `x`
Strings, bytes, and runes
A string is an immutable sequence of bytes. Ranging over a string yields runes (Unicode code points), not bytes:
s := "héllo" // the second char is e-acute
for i, r := range s {
fmt.Printf("%d: %c\n", i, r)
}
Note the byte index jumps by 2 at the accented character because it occupies two UTF-8 bytes.
Structs
Structs aggregate fields into a single type:
type Point struct {
X, Y int
}
p := Point{X: 1, Y: 2}
q := Point{3, 4} // positional
fmt.Println(p.X, q.Y) // 1 4
Structs are value types: assigning one copies all fields. Use a pointer when you want to share or mutate:
func moveRight(p *Point) {
p.X++ // Go auto-dereferences: no (*p).X needed
}
Control flow
Go has if, for, and switch - and that is essentially all the control flow. There is no while; for covers every case:
for i := 0; i < 3; i++ { fmt.Println(i) } // classic
for x < 10 { x *= 2 } // "while"
for { break } // infinite loop
if can carry an initializer, scoping a variable to the statement:
if v := compute(); v > 0 {
fmt.Println("positive", v)
}
switch needs no break (cases do not fall through by default), can switch on no value (replacing long if/else chains), and can switch on a type:
switch {
case score >= 90:
grade = "A"
case score >= 80:
grade = "B"
default:
grade = "C"
}
Pointers, briefly
Go has pointers but no pointer arithmetic. &x takes an address, *p dereferences. There is no manual free; a garbage collector reclaims memory. This gives you the control of pointers without the danger of dangling references.
x := 42
p := &x
*p = 100
fmt.Println(x) // 100
Reference
The language specification is precise and surprisingly readable, and "Effective Go" explains the idioms behind these choices.
- The Go Programming Language Specification: https://go.dev/ref/spec
- Effective Go: https://go.dev/doc/effective_go
Next we will turn declarations into reusable behavior with functions, methods, and interfaces.
Functions, Methods, and Interfaces
Multiple returns, methods on types, and implicit interfaces.
Functions, Methods, and Interfaces
Functions are first-class values in Go, methods attach behavior to types, and interfaces describe behavior without inheritance. Together they form Go's approach to polymorphism - composition over class hierarchies.
Functions and multiple return values
A function lists its parameters and results after the name. Go famously supports multiple return values, which is how it reports errors:
func divide(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("cannot divide %d by zero", a)
}
return a / b, nil
}
q, err := divide(10, 2)
When several parameters share a type, you can write it once: func add(a, b int) int.
Named results and naked returns
Results can be named, which documents intent and lets you use a bare return:
func split(sum int) (x, y int) {
x = sum * 4 / 9
y = sum - x
return // returns x and y
}
Use named returns sparingly - they are clearest in short functions.
Variadic functions
A final parameter written ...T accepts any number of arguments, received as a slice:
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
sum(1, 2, 3) // 6
xs := []int{4, 5, 6}
sum(xs...) // spread a slice with ...
Functions as values and closures
Functions are values you can pass around and return. A closure captures variables from its enclosing scope:
func adder() func(int) int {
sum := 0
return func(x int) int {
sum += x // captures sum by reference
return sum
}
}
a := adder()
fmt.Println(a(1), a(2), a(3)) // 1 3 6
defer
defer schedules a call to run when the surrounding function returns, no matter how it returns. It is the idiom for cleanup:
func readConfig(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // always runs on the way out
// ... use f ...
return nil
}
Deferred calls run in last-in, first-out order, and their arguments are evaluated at the moment defer executes.
Methods
A method is a function with a receiver attached to a type:
type Rectangle struct{ W, H float64 }
func (r Rectangle) Area() float64 {
return r.W * r.H
}
func (r *Rectangle) Scale(f float64) {
r.W *= f
r.H *= f
}
rect := Rectangle{3, 4}
fmt.Println(rect.Area()) // 12
rect.Scale(2) // pointer receiver mutates rect
Use a value receiver (r Rectangle) when the method only reads, and a pointer receiver (r *Rectangle) when it mutates the receiver or the struct is large. Go automatically takes the address when you call a pointer-receiver method on an addressable value.
Interfaces are implicit
An interface lists method signatures. A type satisfies an interface simply by having those methods - there is no implements keyword:
type Shape interface {
Area() float64
}
func describe(s Shape) {
fmt.Printf("area = %.1f\n", s.Area())
}
describe(Rectangle{3, 4}) // works: Rectangle has Area()
This "structural typing" is liberating: you can write an interface in your package that types in other packages satisfy without knowing your interface exists. The standard library is built on small interfaces like io.Reader and io.Writer, each with a single method, that compose endlessly.
The empty interface and type assertions
any (an alias for interface{}) holds a value of any type. Recover the concrete type with a type assertion or a type switch:
var v any = "hello"
if s, ok := v.(string); ok {
fmt.Println("a string:", s)
}
switch x := v.(type) {
case int:
fmt.Println("int", x)
case string:
fmt.Println("string", x)
}
Generics
Since Go 1.18, functions and types can be parameterized by type using constraints:
type Ordered interface {
~int | ~int64 | ~float64 | ~string
}
func Max[T Ordered](a, b T) T {
if a > b {
return a
}
return b
}
Max(3, 7) // 7
Max("a", "b") // "b"
Reach for an interface when you need runtime polymorphism, and generics when you need to write one algorithm that works across many types with compile-time type safety.
Reference
- Methods and interfaces (Tour of Go): https://go.dev/tour/methods/1
- Tutorial: Getting started with generics: https://go.dev/doc/tutorial/generics
Next: the collection types you will reach for constantly - slices and maps.
Collections: Arrays, Slices, and Maps
Go's growable lists and hash tables, and the gotchas around them.
Collections: Arrays, Slices, and Maps
Almost every Go program uses slices and maps. Understanding how they behave - especially how slices share backing arrays - prevents a whole class of subtle bugs.
Arrays: fixed and rarely used directly
An array has a length baked into its type. [3]int and [4]int are different types:
var a [3]int // [0 0 0]
a[0] = 10
b := [...]int{1, 2, 3} // length inferred as 3
Arrays are value types - assigning or passing one copies every element. In practice you almost always use slices instead.
Slices: the workhorse
A slice is a lightweight view into an underlying array. It has three properties: a pointer to an element, a length, and a capacity:
s := []int{2, 3, 5, 7}
fmt.Println(len(s), cap(s)) // 4 4
t := s[1:3] // view of elements 1,2 -> [3 5]
fmt.Println(len(t), cap(t)) // 2 3
Create a slice of a given length with make:
buf := make([]byte, 1024) // len 1024, cap 1024
grow := make([]int, 0, 10) // len 0, cap 10
Append and growth
append adds elements, allocating a bigger backing array when capacity runs out:
nums := []int{1, 2, 3}
nums = append(nums, 4, 5) // [1 2 3 4 5]
more := []int{6, 7}
nums = append(nums, more...) // spread another slice
Always assign the result of append back, because it may return a slice pointing at a new array.
The shared-backing-array gotcha
Because slices share storage, two slices can alias the same data:
a := []int{1, 2, 3, 4, 5}
b := a[1:3] // [2 3], shares a's array
b[0] = 99
fmt.Println(a) // [1 99 3 4 5] -- a changed too!
This is fast (no copying) but means you must be deliberate. To get an independent copy, use copy or the built-in slices.Clone:
c := make([]int, len(b))
copy(c, b)
// or, since Go 1.21:
import "slices"
c := slices.Clone(b)
Iterating
range yields index and value. Use the blank identifier _ to ignore one:
for i, v := range nums {
fmt.Println(i, v)
}
for _, v := range nums { // value only
fmt.Println(v)
}
The slices package
Go 1.21 added a generic slices package with slices.Sort, slices.Contains, slices.Index, slices.Max, and more:
import "slices"
xs := []int{3, 1, 2}
slices.Sort(xs) // [1 2 3]
fmt.Println(slices.Contains(xs, 2)) // true
Maps: hash tables
A map associates keys with values. Create one with make or a literal:
ages := map[string]int{
"Ada": 36,
"Alan": 41,
}
ages["Grace"] = 85
The comma-ok idiom
Reading a missing key returns the value type's zero value, which is ambiguous - was the key present with value 0, or absent? The two-result form disambiguates:
age, ok := ages["Ada"]
if ok {
fmt.Println("found", age)
}
if _, present := ages["Unknown"]; !present {
fmt.Println("not in map")
}
Delete a key with delete (safe even if the key is absent):
delete(ages, "Alan")
Map iteration is randomized
Go deliberately randomizes map iteration order so you never depend on it. To print a map sorted, collect and sort the keys:
keys := make([]string, 0, len(ages))
for k := range ages {
keys = append(keys, k)
}
slices.Sort(keys)
for _, k := range keys {
fmt.Println(k, ages[k])
}
Maps and nil
The zero value of a map is nil. You can read from a nil map (everything is absent) but writing to one panics. Always initialize with make or a literal before assigning. The companion maps package (Go 1.21) adds maps.Keys, maps.Clone, and friends.
Reference
- Go slices: usage and internals (official blog): https://go.dev/blog/slices-intro
- Go maps in action (official blog): https://go.dev/blog/maps
Next we will handle the errors that real programs inevitably produce.
Idiomatic Error Handling
Errors as values, wrapping with %w, and errors.Is/As.
Idiomatic Error Handling
Go has no exceptions for ordinary failures. Instead, errors are values returned from functions and handled explicitly. This makes the failure path visible in the source and impossible to ignore silently.
The error interface
error is just an interface with one method:
type error interface {
Error() string
}
Any type with an Error() string method is an error. Functions that can fail return one as their last result, and nil means success:
f, err := os.Open("config.yaml")
if err != nil {
// handle the failure
return err
}
// use f
The if err != nil check is the single most common pattern in Go code. It is verbose by design: the cost of handling errors is paid up front and in plain sight.
Creating errors
For a simple message use errors.New. For a formatted message use fmt.Errorf:
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func find(id int) error {
if id < 0 {
return fmt.Errorf("invalid id %d", id)
}
return ErrNotFound
}
Storing a sentinel error like ErrNotFound in a package-level variable lets callers compare against it.
Wrapping errors with %w
A low-level error often needs context as it travels up the stack. fmt.Errorf with the %w verb wraps an error, preserving the original while adding a message:
func loadUser(id int) (*User, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("loadUser %d: %w", id, err)
}
// ...
}
The resulting error message reads like a breadcrumb trail: loadUser 7: open /data/7.json: no such file or directory.
Inspecting wrapped errors: Is and As
Because wrapping hides the original error inside a new one, you do not compare with ==. Use errors.Is to check for a specific sentinel anywhere in the chain, and errors.As to extract a specific error type:
_, err := loadUser(7)
if errors.Is(err, os.ErrNotExist) {
fmt.Println("the file is missing")
}
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Println("failed path:", pathErr.Path)
}
errors.Is unwraps repeatedly looking for a match; errors.As finds the first error in the chain that matches the target type and assigns it. This is why wrapping with %w matters - it keeps the chain inspectable.
Custom error types
When callers need structured data about a failure, define a type:
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Msg)
}
func validate(name string) error {
if name == "" {
return &ValidationError{Field: "name", Msg: "required"}
}
return nil
}
A caller can then recover the details:
err := validate("")
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println("bad field:", ve.Field)
}
panic and recover are for the exceptional
panic aborts the current call stack, running deferred functions as it unwinds; recover (only meaningful inside a deferred function) stops the unwinding. These are not Go's error mechanism - reserve them for truly unrecoverable situations (programmer bugs, impossible states) or for crossing a package boundary where you convert a panic back into an error:
func safeDivide(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r)
}
}()
return a / b, nil // dividing by zero panics
}
Idiomatic Go returns errors; it does not throw them. If you find yourself reaching for panic/recover to control normal flow, step back - a returned error is almost always the right tool.
Style guidelines
- Lowercase, no trailing punctuation in error strings:
"open failed", not"Open failed.". They are often wrapped into longer sentences. - Add context as the error rises, but do not log and return the same error - pick one place to handle it.
- Prefer
errors.Is/errors.Asover string matching or==on wrapped errors.
Reference
- Working with Errors in Go (official blog): https://go.dev/blog/go1.13-errors
- Error handling and Go (official blog): https://go.dev/blog/error-handling-and-go
Next we reach Go's headline feature: effortless concurrency.
Concurrency: Goroutines and Channels
Go's signature feature: cheap goroutines and channel communication.
Concurrency: Goroutines and Channels
Concurrency is Go's defining feature. Its model is built on goroutines (extremely cheap, runtime-scheduled threads) and channels (typed pipes for passing values between them). The guiding maxim is: "Do not communicate by sharing memory; instead, share memory by communicating."
Goroutines
Prefix any function call with go to run it concurrently:
func main() {
go fmt.Println("from a goroutine")
fmt.Println("from main")
time.Sleep(time.Millisecond) // crude wait so the goroutine runs
}
A goroutine starts with a tiny stack (a few kilobytes) that grows as needed, so you can launch hundreds of thousands of them. The Go runtime multiplexes them onto a small pool of OS threads. The time.Sleep above is a placeholder - real code coordinates with channels or a WaitGroup instead.
Channels
A channel carries values of a specific type. Create one with make, send with ch <- v, and receive with v := <-ch:
ch := make(chan int)
go func() {
ch <- 42 // send
}()
v := <-ch // receive (blocks until a value arrives)
fmt.Println(v)
An unbuffered channel synchronizes the two goroutines: the send blocks until a receiver is ready, and vice versa. This handshake is how goroutines coordinate without locks.
A buffered channel holds a fixed number of values without a waiting receiver:
ch := make(chan int, 3) // capacity 3
ch <- 1
ch <- 2 // neither send blocks; buffer has room
Closing and ranging
A sender can close a channel to signal "no more values." Receivers can range over a channel until it is closed:
func produce(ch chan<- int) {
for i := 0; i < 3; i++ {
ch <- i
}
close(ch) // tell the receiver we are done
}
func main() {
ch := make(chan int)
go produce(ch)
for v := range ch { // ends when ch is closed
fmt.Println(v)
}
}
The arrow in the parameter type chan<- int marks the channel as send-only inside produce, a compile-time safeguard. <-chan int is receive-only.
select
select waits on multiple channel operations, proceeding with whichever is ready first. Combined with time.After it gives you timeouts:
select {
case v := <-resultCh:
fmt.Println("got", v)
case <-time.After(2 * time.Second):
fmt.Println("timed out")
}
A default case makes select non-blocking - it runs immediately if no channel is ready.
WaitGroup: waiting for many goroutines
When you fan out work, sync.WaitGroup waits for all of it to finish:
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
fetch(u)
}(u)
}
wg.Wait() // blocks until every Done has been called
Pass loop variables as arguments to the goroutine (as u above) to capture the right value. Since Go 1.22 each loop iteration gets a fresh variable, removing the classic capture bug, but passing arguments remains clear and explicit.
A worker pool
Channels make a bounded worker pool natural. Jobs flow in on one channel, results come out on another:
func worker(jobs <-chan int, results chan<- int) {
for j := range jobs {
results <- j * j
}
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
for w := 0; w < 3; w++ { // three workers
go worker(jobs, results)
}
for j := 1; j <= 9; j++ {
jobs <- j
}
close(jobs)
sum := 0
for i := 0; i < 9; i++ {
sum += <-results
}
fmt.Println("sum of squares:", sum) // 285
}
Mutexes when you really do share memory
Channels are preferred, but sometimes a plain shared counter is simpler. sync.Mutex guards it:
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
Detecting races
Concurrent bugs are notoriously hard to find. Go ships a race detector - run your tests or program with -race and it reports unsynchronized access at runtime:
$ go test -race ./...
$ go run -race main.go
Make -race part of your CI; it catches data races that are otherwise invisible until production.
Context for cancellation
Long-running goroutines should respect cancellation. The context package carries deadlines and cancel signals down a call tree; functions select on ctx.Done() to stop early. It is the standard plumbing for servers and request-scoped work.
Reference
- Effective Go – Concurrency: https://go.dev/doc/effective_go#concurrency
- Go Concurrency Patterns (official blog): https://go.dev/blog/pipelines
Finally, we will look at the ecosystem and tooling that ties projects together.
Modules, Testing, and Tooling
Dependency management, the testing package, and the built-in tools.
Modules, Testing, and Tooling
Go's batteries-included philosophy extends to its ecosystem: dependency management, testing, formatting, and static analysis all ship with the go command. This lesson ties the toolchain together for real projects.
Modules and dependencies
A module is a collection of packages versioned together, defined by a go.mod file. Start a project with go mod init:
$ go mod init github.com/you/myapp
Add a dependency by importing it in your code and running go mod tidy, or fetch it explicitly with go get:
$ go get github.com/google/uuid@latest
$ go mod tidy # add missing, remove unused, update go.sum
This updates two files. go.mod lists your direct (and some indirect) requirements with versions:
module github.com/you/myapp
go 1.26
require github.com/google/uuid v1.6.0
go.sum records cryptographic checksums of every dependency for reproducible, verifiable builds. Commit both files to version control. Dependencies use semantic versioning, and the toolchain selects versions using Minimal Version Selection - it picks the lowest version that satisfies all requirements, making builds deterministic.
go get versus go install
The two commands diverged in modern Go. Use go get to manage your project's library dependencies (it edits go.mod). Use go install to build and install a command-line tool into your $GOBIN:
$ go install golang.org/x/tools/cmd/goimports@latest
The explicit @version suffix is required for go install outside a module.
Testing
Tests live beside the code in files ending _test.go. A test is a function TestXxx(t *testing.T) in the testing package - no external framework needed:
// math.go
package mathx
func Add(a, b int) int { return a + b }
// math_test.go
package mathx
import "testing"
func TestAdd(t *testing.T) {
got := Add(2, 3)
if got != 5 {
t.Errorf("Add(2,3) = %d; want 5", got)
}
}
Run it:
$ go test ./...
ok github.com/you/myapp/mathx 0.002s
Table-driven tests
The idiomatic Go pattern packs many cases into a slice of structs and loops over them with subtests:
func TestAddTable(t *testing.T) {
cases := []struct {
name string
a, b, want int
}{
{"positives", 2, 3, 5},
{"with zero", 0, 7, 7},
{"negatives", -1, -2, -3},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := Add(c.a, c.b); got != c.want {
t.Errorf("Add(%d,%d) = %d; want %d", c.a, c.b, got, c.want)
}
})
}
}
Each t.Run is a named subtest you can filter with go test -run TestAddTable/negatives.
Benchmarks and coverage
A BenchmarkXxx(b *testing.B) function measures performance, and the tool reports coverage:
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}
$ go test -bench=. -benchmem
$ go test -cover ./...
$ go test -race ./... # also run the race detector
Example tests as documentation
Functions named ExampleXxx with an // Output: comment are compiled, run, and verified - documentation that cannot rot:
func ExampleAdd() {
fmt.Println(Add(1, 2))
// Output: 3
}
Formatting and static analysis
gofmt (via go fmt) enforces the one canonical style, ending all formatting debates. go vet catches suspicious constructs the compiler allows - wrong Printf verbs, unreachable code, lock copying:
$ go fmt ./...
$ go vet ./...
For deeper checks, the community standard is staticcheck, and goimports formats and fixes import lines automatically. Many teams run these in a pre-commit hook or CI.
Documentation
Doc comments are plain comments immediately above a declaration. go doc reads them from the terminal, and they render on the package site:
$ go doc strings.Builder
$ go doc -all ./mathx
Run go doc on the standard library to learn it without leaving the shell.
Putting it together
A healthy Go project's CI typically runs, in order:
$ go build ./...
$ go vet ./...
$ go test -race -cover ./...
Because the toolchain is uniform, this same pipeline works for any Go project, from a tiny CLI to a large service. That consistency - one toolchain, one formatter, one test runner - is a big part of why Go scales well across teams.
Reference
- Tutorial: Add a test (official): https://go.dev/doc/tutorial/add-a-test
- Go Modules Reference: https://go.dev/ref/mod
You now have the full arc: toolchain, types, functions, collections, errors, concurrency, and the ecosystem. The best next step is to build something small and run go test -race on it.