Intermediate~20 min read

Go

A practical guide to Go's core language features, from structs and interfaces to goroutines, channels, generics, and idiomatic error handling.

GoroutinesChannelsInterfacesError Handling

Program Structure

Every Go source file belongs to a package. An executable program must have a package main with a func main() entry point. Imports are grouped, and unused imports are a compile error, which keeps files tidy.

package main

import (
    "fmt"
    "strings"
)

func main() {
    fmt.Println(strings.ToUpper("hello, go"))
}

Exported names

Identifiers that start with a capital letter are exported (public) from a package. Lowercase names are package-private. There is no public/private keyword.

Variables & Constants

Declare with var, or use the short form := inside functions. Constants are compile-time values, and iota generates incrementing enum-like values.

var count int = 10       // explicit type
var name = "Ada"          // inferred type
age := 30                 // short form (function scope only)

var x, y = 1, 2           // multiple
const Pi = 3.14159

type Weekday int
const (
    Sunday Weekday = iota // 0
    Monday                // 1
    Tuesday               // 2
)

const (
    _  = iota             // skip 0
    KB = 1 << (10 * iota)  // 1024
    MB                    // 1048576
)

Basic Types

Go has fixed-width numeric types, a native bool, and immutable UTF-8 string. The zero value of any variable is well-defined (0, "", false, nil).

CategoryTypes
Integersint, int8..int64, uint, uintptr
Floats / Complexfloat32, float64, complex128
Textstring, rune (int32), byte (uint8)
Otherbool, error

Functions

Functions can return multiple values, which powers Go's error-handling idiom. They also support variadic parameters, closures, and defer for cleanup.

// multiple return values
func divmod(a, b int) (int, int) {
    return a / b, a % b
}
q, r := divmod(17, 5)

// variadic
func sum(nums ...int) int {
    total := 0
    for _, n := range nums {
        total += n
    }
    return total
}
sum(1, 2, 3)          // 6
sum([]int{1, 2}...)   // spread a slice

// closure: captures counter
func counter() func() int {
    i := 0
    return func() int { i++; return i }
}

defer schedules a call to run when the surrounding function returns. Deferred calls run in LIFO order and are ideal for closing resources.

func readFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close() // runs when readFile returns
    // ... use f
    return nil
}

Structs & Methods

A struct groups named fields. Methods attach to a type via a receiver. Choose a pointer receiver when the method mutates the value or the struct is large.

type Counter struct {
    Name  string
    Total int
}

// value receiver: gets a copy, cannot mutate original
func (c Counter) Label() string {
    return fmt.Sprintf("%s=%d", c.Name, c.Total)
}

// pointer receiver: can mutate the original
func (c *Counter) Inc() {
    c.Total++
}

c := Counter{Name: "hits"}
c.Inc()             // Go auto-takes &c
fmt.Println(c.Label())
AspectValue receiver (c T)Pointer receiver (c *T)
Sees aCopyThe original
Can mutateNo (copy only)Yes
Cost for large structFull copy each callCheap (one pointer)
Best forSmall, immutable typesMutation, large types

Be consistent

If any method on a type needs a pointer receiver, use pointer receivers for all its methods so the method set stays uniform.

Interfaces

Interfaces are satisfied implicitly: any type that has the required methods satisfies the interface, with no implements keyword. This decouples concrete types from the abstractions that use them.

type Stringer interface {
    String() string
}

type Point struct{ X, Y int }

func (p Point) String() string {
    return fmt.Sprintf("(%d, %d)", p.X, p.Y)
}

var s Stringer = Point{1, 2} // Point satisfies Stringer

The empty interface interface{} (aliased as any since Go 1.18) holds any value. Recover the concrete type with a type assertion or a type switch.

var v any = "hello"

// type assertion (comma-ok form is safe)
if s, ok := v.(string); ok {
    fmt.Println(len(s))
}

// type switch
func describe(v any) string {
    switch x := v.(type) {
    case int:
        return fmt.Sprintf("int %d", x)
    case string:
        return fmt.Sprintf("string %q", x)
    default:
        return "unknown"
    }
}

Arrays, Slices & Maps

Arrays have a fixed length baked into the type. Slices are the everyday workhorse: a lightweight view over a backing array with a length and capacity. make preallocates and append grows a slice, reallocating when capacity is exceeded.

var arr [3]int          // array, length is part of type
nums := []int{1, 2, 3}   // slice literal

s := make([]int, 0, 8)   // len 0, cap 8
s = append(s, 1, 2, 3)
fmt.Println(len(s), cap(s)) // 3 8

sub := nums[1:3]         // slice of nums, shares backing array

// maps
m := map[string]int{"a": 1}
m["b"] = 2
v, ok := m["a"]          // comma-ok: ok is false if missing
delete(m, "a")
for k, val := range m {  // iteration order is randomized
    fmt.Println(k, val)
}

Slices share memory

A re-slice points at the same backing array, so writes through one slice can be seen through another. Use copy(dst, src) when you need an independent copy.

Error Handling

Go treats errors as ordinary values. The built-in error interface has a single Error() string method. Functions return an error as the last value, and callers check if err != nil.

func parse(s string) (int, error) {
    n, err := strconv.Atoi(s)
    if err != nil {
        // wrap with %w to preserve the chain
        return 0, fmt.Errorf("parse %q: %w", s, err)
    }
    return n, nil
}

var ErrNotFound = errors.New("not found")

_, err := parse("abc")
if errors.Is(err, strconv.ErrSyntax) { /* sentinel match */ }

var numErr *strconv.NumError
if errors.As(err, &numErr) {          // extract typed error
    fmt.Println(numErr.Func)
}

Reserve panic for truly unrecoverable situations. A deferred recover can catch a panic and turn it back into an error at a boundary.

func safeRun() (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("recovered: %v", r)
        }
    }()
    panic("boom")
}

Generics

Since Go 1.18, functions and types can take type parameters in square brackets, bounded by constraints. The any constraint allows any type; comparable allows ==; custom constraints use interface unions.

// generic map over a slice
func Map[T, U any](s []T, f func(T) U) []U {
    out := make([]U, len(s))
    for i, v := range s {
        out[i] = f(v)
    }
    return out
}

doubled := Map([]int{1, 2, 3}, func(n int) int { return n * 2 })

// constraint via union of underlying types
type Number interface {
    ~int | ~int64 | ~float64
}

func Sum[T Number](xs []T) T {
    var total T
    for _, x := range xs {
        total += x
    }
    return total
}

The standard library ships generic helpers in slices and maps, and ordering constraints live in cmp.Ordered.

Concurrency

A goroutine is a lightweight thread launched with the go keyword. Goroutines communicate over channels rather than sharing memory directly: "Don't communicate by sharing memory; share memory by communicating."

ch := make(chan int)        // unbuffered
buf := make(chan int, 4)    // buffered, capacity 4

go func() {
    ch <- 42                // send blocks until received
}()
v := <-ch                   // receive

close(buf)
for x := range buf {        // ranges until channel is closed
    fmt.Println(x)
}
OperationSyntaxBehavior
Sendch <- vBlocks until a receiver (or buffer space) is ready
Receivev := <-chBlocks until a value is available
Receive + okv, ok := <-chok is false when the channel is closed and drained
Directionalchan<- int / <-chan intSend-only / receive-only channel type

select waits on multiple channel operations; sync.WaitGroup waits for a group of goroutines to finish; sync.Mutex protects shared state.

select {
case v := <-ch:
    fmt.Println("got", v)
case <-time.After(time.Second):
    fmt.Println("timeout")
default:
    fmt.Println("nothing ready")
}

var wg sync.WaitGroup
for i := 0; i < 3; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        fmt.Println("worker", id)
    }(i)
}
wg.Wait()

var mu sync.Mutex
mu.Lock()
// critical section
mu.Unlock()

context.Context carries cancellation signals and deadlines across API boundaries and goroutines. Always pass it as the first argument.

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

select {
case <-ctx.Done():
    fmt.Println("cancelled:", ctx.Err())
case result := <-work(ctx):
    fmt.Println(result)
}

Detect data races

Run tests and binaries with the -race flag (go test -race ./...) to catch unsynchronized concurrent access at runtime.

Standard Library Highlights

Go's batteries-included standard library covers most day-to-day needs. A few workhorses:

// net/http: a full server in a few lines
http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "hi")
})
http.ListenAndServe(":8080", nil)

// encoding/json
type User struct {
    Name string `json:"name"`
    Age  int    `json:"age,omitempty"`
}
data, _ := json.Marshal(User{Name: "Ada", Age: 36})
var u User
json.Unmarshal(data, &u)

// os & io
b, _ := os.ReadFile("config.txt")
io.Copy(os.Stdout, strings.NewReader("stream this"))

Tooling & Modern Features

The go command is the single entry point for building, testing, and dependency management via modules.

go mod init example.com/app  # start a module
go get github.com/pkg/errors # add a dependency
go mod tidy                   # sync go.mod / go.sum
go build ./...                # compile everything
go run .                      # build and run
go test ./... -race -cover    # test with race + coverage
go vet ./...                  # static analysis
gofmt -w .                    # format in place

Recent releases removed long-standing footguns. Since Go 1.22, each loop iteration gets its own copy of the loop variable, so the classic goroutine-capture bug is gone. Go 1.22 also added range-over-integer, and Go 1.23 added range-over-function iterators.

// Go 1.22+: range over an int
for i := range 5 {
    fmt.Println(i) // 0 1 2 3 4
}

// Go 1.22+: loop var is per-iteration; this is now safe
for _, v := range items {
    go func() { process(v) }() // v is distinct each iteration
}

Practice Exercises

  1. Write a Stack[T any] generic type with Push, Pop, and Len methods, where Pop returns a value and an error when empty.
  2. Implement a Shape interface with an Area() float64 method, then satisfy it with Circle and Rectangle and sum a slice of shapes.
  3. Build a worker pool: fan out N goroutines that read jobs from a channel and write results to another channel, coordinating completion with a sync.WaitGroup.
  4. Write a function that wraps an error with %w and a caller that uses both errors.Is and errors.As to inspect the chain.
  5. Create an HTTP server with net/http that returns JSON, and enforce a request timeout using context.Context.
  6. Use select with a time.After timeout to consume from a channel, printing "timeout" if no value arrives within one second.

Section navigation