Go 1.26-era refresher • language to production

Relearn Go without leaving the important parts behind.

A structured, searchable reference covering syntax, type semantics, generics, the runtime, channels, thread safety, standard packages, web/data engineering, testing, tools, and modern ecosystem choices.

75explained topics
100+coverage checklist items
Single fileworks locally; progress persists

Exhaustive content plan

This inventory was used before writing the lessons. The detailed cards below implement the plan one topic at a time.

Language foundations

✓ program/package structure✓ declarations and zero values✓ constants and iota✓ numeric types and conversions✓ strings/bytes/runes/UTF-8✓ arrays✓ slices and backing arrays✓ maps and sets✓ if/for/range/switch✓ functions and closures✓ pointers✓ structs and tags✓ embedding✓ methods and receivers✓ method sets✓ interfaces and nil interfaces✓ type assertions/switches✓ generics and constraints✓ iterators✓ errors/wrapping/custom errors✓ defer/panic/recover

Concurrency and runtime

✓ concurrency vs parallelism✓ goroutines✓ channels✓ buffering and close semantics✓ select✓ timeouts✓ context cancellation✓ WaitGroup✓ errgroup✓ worker pools✓ semaphores✓ backpressure✓ pipelines/fan-in/fan-out✓ Mutex/RWMutex✓ Once/Cond/Pool✓ atomics✓ memory model✓ race detector✓ scheduler G-M-P✓ GOMAXPROCS✓ OS threads and blocking calls✓ multiprocessing✓ goroutine leak prevention

Core standard library

✓ io.Reader/io.Writer✓ bufio✓ os/filepath/io/fs✓ encoding/json✓ CSV/XML/base64/hex/binary/gob✓ time/timers/tickers✓ strings/bytes/strconv/regexp✓ log/slog✓ crypto/rand/hashes/TLS✓ reflect/unsafe✓ os/exec✓ flag✓ embed✓ testing/httptest✓ runtime/debug

Web, data, and production

✓ net/http routing✓ middleware✓ HTTP client/transport✓ SSE/WebSocket/streaming✓ gRPC/Connect✓ templates/static assets/frontend bundling✓ authentication/OAuth/OIDC/JWT/sessions✓ authorization✓ validation/CORS/CSRF/rate limiting✓ database/sql✓ transactions✓ pgx/sqlc/ORMs✓ migrations✓ caching/Redis✓ queues/events/outbox/idempotency✓ configuration/secrets✓ observability✓ graceful shutdown✓ health/readiness✓ resilience✓ containers✓ profiling/tracing/escape analysis✓ security/supply chain

Tooling and ecosystem

✓ modules/go.sum✓ workspaces✓ toolchain selection✓ build tags✓ cross compilation✓ cgo✓ gofmt/goimports/vet✓ Staticcheck/golangci-lint✓ go generate/codegen✓ fuzzing✓ benchmarks/benchstat✓ mocks/fakes/testcontainers✓ routers/frameworks✓ CLI tools✓ OpenAPI/protobuf/GraphQL✓ dependency injection✓ golang.org/x packages✓ deployment stack choices
Start

How to use this guide

Use the left navigation as a refresher path, or search for a symbol, package, or production concern. Mark topics complete as you revisit them; progress is saved in your browser.

A practical sequence is: syntax → types → methods/interfaces → errors/resources → concurrency → HTTP/data → testing/tooling → production architecture.

Every example favors idiomatic, current Go. The ecosystem recommendations are choices, not mandatory dependencies. Start with the standard library and add packages when they remove meaningful boilerplate or provide a capability you would otherwise implement poorly.

roadmap learning
Start

Install, verify, and create a module

A module is the dependency and versioning unit. A package is a directory of Go files compiled together.

Common commands: • go version: verify the selected toolchain. • go env: inspect environment and module settings. • go mod init: create go.mod. • go run .: compile and run the current main package. • go build ./...: build every package below the module. • go test ./...: test every package. • go install package@version: install a command without changing your module.

Go 1.21+ can select/download a suitable toolchain based on the go and toolchain lines in go.mod.

mkdir inventory && cd inventory
go mod init example.com/inventory
go run .
go test ./...
go fmt ./...
go vet ./...
modules toolchain commands
Start

Project and package layout

Prefer simple, domain-oriented packages. Add layers only when the project actually needs them.

Typical service: cmd/api/main.go — composition root and process lifecycle. internal/order — domain/application code private to the module. internal/httpapi — HTTP adapters. internal/postgres — persistence adapter. migrations — SQL migrations. web/dist — built frontend. api — OpenAPI/protobuf definitions.

There is no required src directory. The internal directory has compiler-enforced import restrictions. Avoid generic dumping grounds named utils, common, or helpers.

inventory/
├── go.mod
├── cmd/api/main.go
├── internal/
│   ├── order/
│   │   ├── service.go
│   │   └── repository.go
│   ├── httpapi/handler.go
│   └── postgres/order_repository.go
├── migrations/
└── web/dist/
layout packages architecture
Language

Program structure, declarations, and zero values

Go favors declarations with useful zero values. Package initialization runs before main, but explicit construction is easier to test.

var declares a value and receives its zero value unless initialized. := is shorthand inside functions and requires at least one new variable. const values are compile-time values and can be untyped. iota creates successive integer constants.

Zero values: numeric 0, false, "", nil for pointers/slices/maps/channels/functions/interfaces, and recursively zeroed structs.

package main

import "fmt"

const (
    Pending = iota
    Running
    Done
)

var serviceName = "inventory"

func main() {
    count := 3
    var enabled bool // false
    fmt.Println(serviceName, count, enabled, Pending)
}
syntax zero value iota
Language

Built-in types and conversions

Go does not perform most implicit numeric conversions. Convert deliberately and watch for overflow or truncation.

Integers: int/uint (machine word), fixed-width int8…uint64, uintptr. Floats: float32/float64. Complex: complex64/complex128. Aliases: byte = uint8, rune = int32. Strings are immutable byte sequences, conventionally UTF-8 but not guaranteed valid UTF-8. Conversions use T(x); they are not runtime type assertions.

var i int = 42
f := float64(i)
b := byte(255)
r := rune('界')

s := "Go 世界"
fmt.Println(len(s))                    // bytes
fmt.Println(utf8.RuneCountInString(s)) // Unicode code points
fmt.Println(f, b, r)
types conversion unicode
Language

Strings, bytes, runes, and UTF-8

Indexing a string returns a byte. Ranging over it decodes UTF-8 runes and reports byte offsets.

Use strings.Builder for incremental text, bytes.Buffer for bytes plus io.Reader/io.Writer behavior, strconv for parsing/formatting, and unicode/utf8 when validating or decoding manually. Converting string ↔ []byte allocates in normal code.

s := "A界"
fmt.Println(len(s)) // 4 bytes

for byteIndex, r := range s {
    fmt.Printf("%d: %c\n", byteIndex, r)
}

var b strings.Builder
b.Grow(32)
b.WriteString("hello")
b.WriteByte(' ')
b.WriteString("world")
result := b.String()
strings bytes runes UTF-8
Language

Arrays, slices, capacity, and append

An array owns fixed-size storage. A slice is a small descriptor pointing to an array: pointer, length, and capacity.

append may reuse the backing array or allocate a new one. Therefore, append's return value must be assigned. Sub-slices can keep a large backing array alive; copy out a small retained portion when necessary. Use full slice expressions a[low:high:max] to limit capacity.

A nil slice and empty slice both have length zero, but can encode differently in JSON.

a := [4]int{10, 20, 30, 40}
s := a[1:3]          // len=2 cap=3
s = append(s, 99)    // may modify a[3]

dst := make([]int, len(s))
copy(dst, s)

limited := a[1:3:3]  // cap=2; append must allocate
limited = append(limited, 77)
array slice append capacity
Language

Maps and sets

Maps are reference-like hash tables. Reading a missing key returns the value type's zero value; use the comma-ok form to distinguish absence.

A nil map can be read but not written. Map iteration order is unspecified. Concurrent read/write access requires synchronization. A set is commonly map[T]struct{}.

Delete is safe for missing keys. clear(map) removes all entries in modern Go.

counts := make(map[string]int)
counts["go"]++

n, exists := counts["rust"]
delete(counts, "rust")

seen := map[string]struct{}{}
seen["order-42"] = struct{}{}
_, alreadySeen := seen["order-42"]

clear(counts)
fmt.Println(n, exists, alreadySeen)
map set comma ok
Language

Control flow: if, for, range, switch

Go has one loop keyword: for. Conditions have no parentheses. Initializers can scope temporary values.

range works with arrays, slices, strings, maps, channels, integers, and iterator functions supported by current Go. Be aware whether you need the index, a copy of the value, or the address of an actual element.

switch breaks automatically; fallthrough is explicit. Type switches inspect interface dynamic types.

if n, err := strconv.Atoi(input); err != nil {
    return fmt.Errorf("parse count: %w", err)
} else if n < 0 {
    return errors.New("count must be non-negative")
}

for i := range 5 {
    fmt.Println(i)
}

switch v := anyValue.(type) {
case string:
    fmt.Println(strings.ToUpper(v))
case int:
    fmt.Println(v * 2)
default:
    fmt.Printf("%T\n", v)
}
if for range switch
Language

Functions, multiple returns, variadics, closures

Functions are values. Multiple returns make result-plus-error idiomatic. Closures capture variables, not frozen values.

Named returns can help tiny functions but often reduce clarity in larger ones. Variadic arguments arrive as a slice. Pass an existing slice with values....

Closures are useful for handlers and options; be careful when captured state is used concurrently.

func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

func sum(values ...int) int {
    total := 0
    for _, v := range values {
        total += v
    }
    return total
}

nums := []int{1, 2, 3}
fmt.Println(sum(nums...))
functions closures variadic
Language

Pointers and value semantics

Go passes every argument by value. Passing a pointer copies the pointer, allowing the callee to modify the pointed-to value.

Use pointers when mutation, identity, avoiding a meaningful copy, or representing optionality is required. Do not use pointers merely because a struct is “large” without measurement. new(T) allocates a zero T and returns *T; composite literals are more common.

Go has no pointer arithmetic outside unsafe.

type Counter struct{ N int }

func Increment(c *Counter) {
    if c == nil {
        return
    }
    c.N++
}

c := Counter{}
Increment(&c)
fmt.Println(c.N)
pointers value semantics
Language

Structs, embedding, tags, and composition

Struct embedding promotes fields and methods; it is composition, not inheritance.

Use named fields when clarity matters. Tags are string metadata interpreted by packages such as encoding/json and validation libraries. Exported identifiers begin with an uppercase letter.

Embedding an interface can define a larger interface; embedding a concrete value reuses behavior but also exposes promoted methods, so use it intentionally.

type Address struct {
    City string `json:"city"`
}

type User struct {
    ID      int64   `json:"id"`
    Name    string  `json:"name"`
    Address Address `json:"address"`
}

type TimedUser struct {
    User
    CreatedAt time.Time `json:"created_at"`
}
struct embedding tags composition
Language

Methods, receivers, and method sets

A method belongs to a named receiver type. Pointer receivers can mutate and avoid copying; value receivers operate on a copy.

Choose one receiver style consistently for a type. Use pointer receivers if any method needs one, the type contains synchronization primitives, or copying is unsafe/expensive.

Method sets matter for interface satisfaction: T has value-receiver methods; *T has both value- and pointer-receiver methods. The compiler often inserts & or * for addressable direct calls, but interface assignment follows method-set rules.

type Account struct{ Balance int64 }

func (a Account) Empty() bool { return a.Balance == 0 }

func (a *Account) Deposit(cents int64) {
    a.Balance += cents
}

type Depositor interface {
    Deposit(int64)
}

var d Depositor = &Account{} // *Account satisfies it
methods receiver method set
Language

Interfaces and structural satisfaction

A type satisfies an interface implicitly by having its methods. Keep interfaces small and define them near the consumer.

Accept interfaces when multiple implementations or test boundaries are useful; return concrete types by default. The empty interface is any. Type assertions recover a dynamic value.

Interface values contain a dynamic type and dynamic value. An interface holding a typed nil pointer is itself non-nil—the classic nil-interface trap.

type Store interface {
    Get(ctx context.Context, id string) (Item, error)
}

func Load(ctx context.Context, s Store, id string) (Item, error) {
    return s.Get(ctx, id)
}

var p *bytes.Buffer = nil
var w io.Writer = p
fmt.Println(w == nil) // false: dynamic type is *bytes.Buffer
interface structural typing nil trap
Language

Generics: constraints, inference, and useful patterns

Generics parameterize functions and types over sets of types. Prefer ordinary interfaces when behavior—not representation—is the abstraction.

A constraint is an interface used at compile time. ~T admits types whose underlying type is T. comparable permits == and map keys. Type inference often removes explicit type arguments.

Good uses: containers, algorithms, typed helpers. Poor uses: hiding domain distinctions or replacing simple interfaces with elaborate constraints.

type Ordered interface {
    ~int | ~int64 | ~float64 | ~string
}

func Min[T Ordered](a, b T) T {
    if a < b {
        return a
    }
    return b
}

type Set[T comparable] map[T]struct{}

func (s Set[T]) Add(v T) { s[v] = struct{}{} }

names := Set[string]{}
names.Add("gopher")
fmt.Println(Min(4, 9))
generics constraints type inference
Language

Iterators and sequence-style APIs

Modern Go supports range-over-function iterator patterns through iter.Seq and iter.Seq2.

An iterator calls a yield function until the sequence ends or yield returns false. This allows lazy traversal while retaining familiar range syntax. Keep ownership and cleanup obvious; for I/O, document how iteration errors are surfaced.

func Countdown(n int) iter.Seq[int] {
    return func(yield func(int) bool) {
        for i := n; i >= 0; i-- {
            if !yield(i) {
                return
            }
        }
    }
}

for n := range Countdown(3) {
    fmt.Println(n)
}
iterators iter.Seq range function
Language

Error values, wrapping, joining, and matching

Errors are ordinary values. Add context while preserving the cause with %w.

Use errors.Is for sentinel/cause matching and errors.As for typed errors. Avoid string matching. errors.Join combines independent failures. Sentinel errors are useful when callers need a stable category; custom types carry structured details.

Handle an error once: return it, translate it, or log at a process boundary—not all three at every layer.

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

func Load(id string) error {
    err := query(id)
    if err != nil {
        return fmt.Errorf("load item %q: %w", id, err)
    }
    return nil
}

if err := Load("42"); errors.Is(err, ErrNotFound) {
    // map to HTTP 404
}

var pathErr *os.PathError
if errors.As(err, &pathErr) {
    fmt.Println(pathErr.Path)
}
errors wrapping errors.Is errors.As
Language

defer, panic, recover, and resource lifetime

defer schedules a call for the surrounding function's return, in LIFO order. Use it immediately after acquiring a resource.

Deferred arguments are evaluated when defer executes; deferred closures read captured variables later. panic is for unrecoverable programmer/runtime conditions, not routine validation. recover works only inside a deferred function in the panicking goroutine.

Servers commonly recover at the request boundary so one bad request does not terminate the process, while still logging the stack.

func readFile(path string) ([]byte, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer f.Close()
    return io.ReadAll(f)
}

func protect(fn func()) {
    defer func() {
        if v := recover(); v != nil {
            log.Printf("panic: %v\n%s", v, debug.Stack())
        }
    }()
    fn()
}
defer panic recover resources
Concurrency

Concurrency vs parallelism vs multiprocessing

Concurrency structures independent tasks. Parallelism executes work simultaneously. Multiprocessing uses multiple OS processes.

Goroutines are user-space scheduled tasks multiplexed over OS threads. They can run in parallel when GOMAXPROCS allows multiple CPUs. Go programs are normally multithreaded without you creating threads directly.

Use multiple processes for isolation, independent scaling, privilege boundaries, or separate failure domains. Communicate via HTTP/gRPC, queues, pipes, sockets, or shared external storage—not Go channels, which are in-process.

func main() {
    fmt.Println("logical CPUs:", runtime.NumCPU())
    fmt.Println("parallelism:", runtime.GOMAXPROCS(0))

    cmd := exec.CommandContext(context.Background(), "worker", "--job=42")
    output, err := cmd.CombinedOutput() // a separate process
    fmt.Println(string(output), err)
}
parallelism multiprocessing threads scheduler
Concurrency

Goroutines and lifecycle ownership

The go statement starts a goroutine. Every goroutine should have a clear termination condition and owner.

A goroutine leak is a task blocked forever on I/O, channel operations, locks, or forgotten timers. Pass cancellation, close owned resources, and bound concurrency. A process exits when main returns; it does not wait for other goroutines.

Do not “fire and forget” important work. Tie it to a supervised worker, durable queue, or shutdown protocol.

func poll(ctx context.Context, interval time.Duration) {
    ticker := time.NewTicker(interval)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            refresh()
        }
    }
}

ctx, cancel := context.WithCancel(context.Background())
go poll(ctx, time.Second)
cancel()
goroutine lifecycle leak cancellation
Concurrency

Channels: unbuffered, buffered, close, and direction

Channels synchronize and transfer values between goroutines. An unbuffered send pairs with a receive; a buffered send waits only when the buffer is full.

Only the sending/owning side should close. Closing means no more values will be sent; it is not required for garbage collection. Receiving from a closed channel yields buffered values, then the zero value with ok=false. Sending to or closing a closed channel panics.

Channel directions document APIs: chan<- T send-only, <-chan T receive-only.

func producer(ctx context.Context) <-chan int {
    out := make(chan int, 2)
    go func() {
        defer close(out)
        for _, n := range []int{1, 2, 3} {
            select {
            case out <- n:
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}

for n := range producer(context.Background()) {
    fmt.Println(n)
}
channel buffer close direction
Concurrency

select, timeouts, cancellation, and nil channels

select waits until a communication case can proceed. If several are ready, one is chosen pseudo-randomly.

default makes select non-blocking and can accidentally create a busy loop. time.After is convenient for one-off waits; reusable loops should use Timer/Ticker and stop them. A nil channel blocks forever, which can be useful for dynamically disabling a select case.

timer := time.NewTimer(500 * time.Millisecond)
defer timer.Stop()

select {
case result := <-results:
    fmt.Println(result)
case <-timer.C:
    return errors.New("timed out")
case <-ctx.Done():
    return ctx.Err()
}
select timeout nil channel timer
Concurrency

WaitGroup and structured waiting

sync.WaitGroup waits for a known group of goroutines. Add before starting the goroutine; each worker calls Done.

A WaitGroup does not propagate errors or cancellation. For groups of tasks that can fail, errgroup is often a better fit. Never copy a WaitGroup after first use.

var wg sync.WaitGroup

for _, job := range jobs {
    job := job
    wg.Add(1)
    go func() {
        defer wg.Done()
        process(job)
    }()
}

wg.Wait()
WaitGroup join structured concurrency
Concurrency

errgroup: cancellation plus first error

golang.org/x/sync/errgroup coordinates related goroutines and cancels derived work when one returns an error.

SetLimit bounds active goroutines. Go waits for completion and returns the first non-nil error. Use the group-derived context for downstream operations; do not start unrelated lifetime work in a request-scoped group.

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)

for _, id := range ids {
    id := id
    g.Go(func() error {
        return fetchAndStore(ctx, id)
    })
}

if err := g.Wait(); err != nil {
    return fmt.Errorf("sync records: %w", err)
}
errgroup cancellation errors bounded concurrency
Concurrency

Worker pools, semaphores, and backpressure

Bound concurrency when work consumes database connections, file descriptors, memory, CPU, or remote-service capacity.

A worker pool uses fixed workers reading a queue. A semaphore limits concurrent entries without prescribing a persistent pool. Backpressure means producers eventually wait or reject work rather than allowing an unbounded queue.

Choose queue size from service objectives, not guesswork. Expose saturation metrics.

jobs := make(chan Job, 100)
var wg sync.WaitGroup

for range 8 {
    wg.Add(1)
    go func() {
        defer wg.Done()
        for job := range jobs {
            handle(job)
        }
    }()
}

for _, job := range input {
    jobs <- job // backpressure when queue is full
}
close(jobs)
wg.Wait()
worker pool semaphore backpressure queue
Concurrency

Pipelines, fan-out, fan-in

Pipeline stages transform streams; fan-out parallelizes a stage; fan-in merges outputs.

Cancellation must reach every stage, especially send operations, or downstream early exit can leak upstream goroutines. For simple finite data, a bounded errgroup over a slice is often easier than a channel pipeline.

func square(ctx context.Context, in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            select {
            case out <- n * n:
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}
pipeline fan-out fan-in leak prevention
Concurrency

Mutex, RWMutex, Cond, Once, and Pool

Use locks to protect invariants over shared memory. Keep critical sections small and never copy a used lock.

Mutex is the default. RWMutex helps only for measured read-heavy contention; it adds complexity. Once performs one successful call attempt (a panic still marks it done). Cond coordinates state changes when channels are awkward. Pool reuses temporary objects and may discard them at any time—never store required state there.

Document which fields a mutex protects.

type Cache struct {
    mu sync.RWMutex
    m  map[string]string
}

func (c *Cache) Get(k string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    v, ok := c.m[k]
    return v, ok
}

func (c *Cache) Put(k, v string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.m[k] = v
}
mutex RWMutex Once Pool Cond
Concurrency

Atomics and lock-free state

sync/atomic provides indivisible operations and memory-ordering guarantees. Prefer locks unless a tiny independent state or measured hot path justifies atomics.

Typed atomics such as atomic.Int64, Bool, Pointer[T], and Value reduce misuse. Multiple atomic fields do not automatically form one invariant. CompareAndSwap enables state transitions but lock-free algorithms are subtle.

Use atomic counters for metrics and atomic.Value/Pointer for read-mostly snapshots.

type ConfigStore struct {
    current atomic.Pointer[Config]
}

func (s *ConfigStore) Load() *Config {
    return s.current.Load()
}

func (s *ConfigStore) Replace(c *Config) {
    clone := *c
    s.current.Store(&clone)
}

var requests atomic.Uint64
requests.Add(1)
atomic CAS lock-free memory ordering
Concurrency

Go memory model and happens-before

Race-free Go programs have predictable sequentially consistent behavior. Synchronization establishes ordering between goroutines.

Examples of synchronizing events include mutex unlock→later lock, channel send→corresponding receive, channel close→receive observing closure, and atomic operations under their specified ordering. Starting a goroutine does not make later unsynchronized writes safely visible.

Do not use sleep as synchronization. Protect shared mutable data or transfer ownership.

var (
    mu    sync.Mutex
    ready bool
    data  string
)

go func() {
    mu.Lock()
    data = "published"
    ready = true
    mu.Unlock()
}()

mu.Lock()
if ready {
    fmt.Println(data)
}
mu.Unlock()
memory model happens-before visibility race
Concurrency

Race detector and common race patterns

A data race occurs when concurrent accesses touch the same memory, at least one is a write, and there is no synchronization.

Run go test -race ./... and exercise realistic paths. It detects races that execute; it is not a proof of absence. Common races: maps, counters, captured variables, lazy initialization, and shared response/request objects.

Fix by confinement, channels, locks, atomics, immutability, or copying—not by adding sleeps.

go test -race ./...
go run -race .
go test -race -count=10 ./internal/...

// Safe counter:
var mu sync.Mutex
count := 0
mu.Lock()
count++
mu.Unlock()
race detector thread safety data race
Concurrency

Scheduler, OS threads, blocking calls, and GOMAXPROCS

The runtime schedules goroutines over OS threads using a G-M-P model: goroutines, machine threads, and logical processors.

Blocking network I/O integrates with the runtime poller. Blocking syscalls or cgo calls may occupy threads; the runtime can create others. GOMAXPROCS controls how many logical processors execute Go code simultaneously, not the number of goroutines or threads.

runtime.LockOSThread is rare: UI/event loops, thread-local foreign APIs, or namespace/credential operations tied to a thread.

previous := runtime.GOMAXPROCS(0)
fmt.Println("current GOMAXPROCS:", previous)

// Rare:
runtime.LockOSThread()
defer runtime.UnlockOSThread()
callThreadAffineAPI()
scheduler GOMAXPROCS OS thread blocking I/O
Concurrency

Context: deadlines, cancellation, and request scope

context.Context carries cancellation, deadlines, and request-scoped metadata across API boundaries.

Pass ctx as the first parameter; do not store it in structs for normal request work. Always call the returned cancel function. Values are for cross-cutting metadata such as trace IDs, not optional function parameters. Libraries should accept cancellation and return promptly.

Background starts a root; TODO marks an unresolved choice.

func Fetch(ctx context.Context, client *http.Client, url string) error {
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()

    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return err
    }
    resp, err := client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    return nil
}
context deadline cancellation request scope
Standard Library

io abstractions and streaming

io.Reader and io.Writer are Go's central streaming interfaces. Compose them instead of loading entire payloads.

Useful tools: io.Copy, CopyN, LimitReader, TeeReader, MultiReader, MultiWriter, Pipe, ReadAll. bufio adds buffering, Scanner, and tokenization. Scanner has a default token-size limit; increase Buffer or use Reader for large records.

Always limit untrusted body sizes.

limited := io.LimitReader(resp.Body, 10<<20) // 10 MiB
hash := sha256.New()

n, err := io.Copy(io.MultiWriter(dst, hash), limited)
if err != nil {
    return err
}
fmt.Printf("copied=%d sha256=%x\n", n, hash.Sum(nil))
io.Reader io.Writer streaming buffering
Standard Library

Files, paths, and filesystem APIs

Use os for files/process environment, filepath for OS paths, path for slash-separated URL-style paths, and io/fs for abstract filesystems.

Prefer os.ReadFile/WriteFile for small bounded files and streaming for large ones. Use fs.ValidPath conventions for embedded/sub filesystems. Atomic replacement usually means write a temporary file in the same directory, sync as required, then rename.

data, err := os.ReadFile("config.json")
if err != nil {
    return err
}

if err := os.WriteFile("output.txt", data, 0o600); err != nil {
    return err
}

err = filepath.WalkDir("data", func(path string, d fs.DirEntry, err error) error {
    if err != nil { return err }
    fmt.Println(path)
    return nil
})
os filepath io/fs files
Standard Library

JSON, XML, CSV, gob, and binary encoding

encoding/json is ubiquitous but reflection-based. Model wire formats explicitly and validate after decoding.

Use Decoder for streams and DisallowUnknownFields where strict contracts help. Decoder.Decode permits another JSON value unless you explicitly check EOF. Use json.RawMessage for delayed decoding. time.Time uses RFC3339 JSON by default.

CSV, XML, base64, hex, binary, pem, and gob cover common formats. gob is Go-specific and not a public cross-language protocol.

type CreateUser struct {
    Email string `json:"email"`
    Age   int    `json:"age"`
}

dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
dec.DisallowUnknownFields()

var in CreateUser
if err := dec.Decode(&in); err != nil {
    return fmt.Errorf("decode request: %w", err)
}
if err := dec.Decode(&struct{}{}); err != io.EOF {
    return errors.New("request must contain one JSON value")
}
json encoding decoder validation
Standard Library

Time, timers, tickers, and monotonic time

time.Time can carry wall-clock and monotonic readings. Use durations for elapsed time and explicit locations for civil time.

time.Since(start) safely uses monotonic data when available. Store timestamps in UTC and convert for presentation. Stop tickers. Timer reset/drain details matter in reusable timer loops.

Avoid comparing formatted strings. For database/API boundaries, define precision and timezone expectations.

start := time.Now()
defer func() {
    slog.Info("finished", "elapsed", time.Since(start))
}()

deadline := time.Now().Add(30 * time.Second)
utc := deadline.UTC()
local := utc.In(time.Local)

ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
time timer ticker timezone
Standard Library

Regular expressions, strconv, strings, bytes

Use the smallest tool that fits. Prefix/suffix/split operations are clearer and faster than regex for simple parsing.

regexp uses RE2-style linear-time matching and excludes features such as backreferences. strconv handles numeric and boolean parsing/formatting. strings and bytes provide search, trimming, mapping, replacement, builders, and readers.

port, err := strconv.Atoi("8080")
if err != nil {
    return err
}

fields := strings.Fields("  alpha  beta ")
slug := strings.ToLower(strings.Join(fields, "-"))

re := regexp.MustCompile(`^[a-z][a-z0-9-]{2,31}$`)
fmt.Println(port, slug, re.MatchString(slug))
regexp strconv strings parsing
Standard Library

log/slog structured logging

log/slog is the standard structured logging API. Emit stable machine-queryable fields rather than interpolating everything into text.

Create a logger at startup and inject it or derive request loggers with With. Avoid secrets and high-cardinality noise. Use context-aware methods when handlers need context. Handlers control JSON/text output and filtering.

Do not log the same error at every layer; log where ownership crosses a boundary.

handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,
})
logger := slog.New(handler)

requestLog := logger.With(
    "request_id", requestID,
    "method", r.Method,
    "path", r.URL.Path,
)
requestLog.InfoContext(r.Context(), "request completed",
    "status", 200,
    "duration", time.Since(start),
)
slog logging structured logs
Standard Library

Cryptography, hashes, randomness, and secrets

Use crypto/rand for security-sensitive randomness; math/rand/v2 is for simulation and non-secret randomized behavior.

Use password-hashing algorithms from maintained packages such as bcrypt, scrypt, or Argon2 rather than a fast hash. Compare authentication MACs with constant-time functions. Use crypto/tls defaults unless protocol requirements demand careful configuration.

Never invent encryption protocols or store plaintext passwords.

tokenBytes := make([]byte, 32)
if _, err := cryptorand.Read(tokenBytes); err != nil {
    return err
}
token := base64.RawURLEncoding.EncodeToString(tokenBytes)

sum := sha256.Sum256(data) // integrity/fingerprint, not password hashing
fmt.Println(token, hex.EncodeToString(sum[:]))
crypto random hash secrets
Standard Library

Reflection and unsafe

reflect enables runtime type inspection; unsafe bypasses Go's type and memory safety.

Reflection powers serializers, validators, and dependency tools but makes code harder to understand and moves errors to runtime. Prefer generics, interfaces, and generated code when possible.

unsafe may be necessary for systems integration or measured low-level optimization. Keep it isolated, tested across architectures, and documented with lifetime/alignment assumptions.

func fieldNames(v any) []string {
    t := reflect.TypeOf(v)
    if t.Kind() == reflect.Pointer {
        t = t.Elem()
    }
    names := make([]string, 0, t.NumField())
    for i := 0; i < t.NumField(); i++ {
        names = append(names, t.Field(i).Name)
    }
    return names
}
reflect unsafe runtime types
Web & APIs

net/http server fundamentals

The standard HTTP server is production-capable when configured carefully. Handlers are called concurrently.

Set ReadHeaderTimeout, IdleTimeout, and sensible write/read policies. Avoid http.ListenAndServe defaults for internet-facing services. Request bodies must be bounded and closed as appropriate. ResponseWriter usually must not be used after the handler returns.

Go 1.22+ ServeMux supports method-aware patterns and path parameters.

mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    writeJSON(w, http.StatusOK, map[string]string{"id": id})
})

srv := &http.Server{
    Addr:              ":8080",
    Handler:           mux,
    ReadHeaderTimeout: 5 * time.Second,
    IdleTimeout:       60 * time.Second,
}
log.Fatal(srv.ListenAndServe())
net/http ServeMux server routing
Web & APIs

Middleware and handler composition

Standard middleware wraps http.Handler. Keep middleware focused on transport concerns.

Typical middleware: request IDs, access logging, panic recovery, authentication, authorization, CORS, compression, tracing, rate limiting, body limits. Put domain validation and business rules in services, not middleware.

Order matters: recovery should be outside code that might panic; authentication must precede authorization.

func requestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := r.Header.Get("X-Request-ID")
        if id == "" {
            id = newID()
        }
        ctx := context.WithValue(r.Context(), requestIDKey{}, id)
        w.Header().Set("X-Request-ID", id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

handler := requestID(logging(auth(mux)))
middleware http.Handler composition
Web & APIs

HTTP clients, transports, retries, and connection reuse

Reuse http.Client and Transport; they are safe for concurrent use and manage connection pools.

Always set request deadlines through context or client timeout. Read and close response bodies so connections can be reused. Retry only safe/idempotent operations or operations protected with idempotency keys, and use bounded exponential backoff with jitter.

The default client has no overall timeout. Customize Transport carefully rather than creating one per request.

client := &http.Client{
    Timeout: 5 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 20,
        IdleConnTimeout:     90 * time.Second,
    },
}

req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := client.Do(req)
if err != nil { return err }
defer resp.Body.Close()
HTTP client transport retry connection pool
Web & APIs

WebSockets, SSE, streaming, and RPC

Choose a protocol from communication shape and operational constraints.

SSE: server→browser events over HTTP, automatic reconnect semantics, simple proxies. WebSocket: full duplex, custom framing/application protocol. HTTP streaming: files, chunks, NDJSON. gRPC: typed service contracts, streaming, strong internal-service tooling; browser use often needs gRPC-Web or a gateway. Connect: RPC over familiar HTTP semantics with broad client support.

For all streaming: propagate cancellation, handle slow consumers, bound queues, send heartbeats when needed, and define reconnection/resume semantics.

func events(w http.ResponseWriter, r *http.Request) {
    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "streaming unsupported", 500)
        return
    }
    w.Header().Set("Content-Type", "text/event-stream")
    for {
        select {
        case <-r.Context().Done():
            return
        case event := <-updates:
            fmt.Fprintf(w, "event: update\ndata: %s\n\n", event)
            flusher.Flush()
        }
    }
}
SSE WebSocket gRPC streaming
Web & APIs

Templates, static files, and frontend bundling

Go can render server HTML or serve a separately built SPA. go:embed can package assets into one binary.

Server-rendered: html/template automatically context-escapes; templ offers typed generated components; htmx can add interaction with little JS. SPA: build React/Vue/Svelte with Vite or the framework tool, then embed dist/ or serve it from a CDN/object store. For development, run frontend and Go dev servers separately with a proxy. For production, choose embedded assets for simple deployment or CDN for independent releases and global caching.

//go:embed web/dist/*
var assets embed.FS

func staticHandler() http.Handler {
    dist, err := fs.Sub(assets, "web/dist")
    if err != nil {
        panic(err)
    }
    return http.FileServer(http.FS(dist))
}

// Build:
// cd web && npm ci && npm run build
// go build ./cmd/api
embed frontend Vite templates SPA
Web & APIs

Authentication and authorization

Authentication proves identity; authorization decides whether that identity may perform an action.

Browser applications commonly use secure, HttpOnly, SameSite cookies backed by a server session or carefully designed tokens. OAuth 2.0 is authorization delegation; OpenID Connect adds identity. Validate issuer, audience, signature algorithm, expiry, nonce/state, and key rotation.

Do not implement password storage, OAuth, JWT verification, or CSRF protection from scratch. Authorization belongs near use cases/resources, not only at the router.

cookie := &http.Cookie{
    Name:     "__Host-session",
    Value:    sessionID,
    Path:     "/",
    Secure:   true,
    HttpOnly: true,
    SameSite: http.SameSiteLaxMode,
    MaxAge:   3600,
}
http.SetCookie(w, cookie)

// Common packages:
// golang.org/x/oauth2
// github.com/coreos/go-oidc/v3/oidc
// github.com/golang-jwt/jwt/v5 (when JWT is truly needed)
auth OAuth2 OIDC JWT session
Web & APIs

Validation, API errors, CORS, CSRF, and rate limits

Treat transport decoding, syntactic validation, domain validation, and authorization as separate steps.

Return consistent machine-readable errors with safe messages. CORS is a browser policy, not authentication. Cookie-authenticated state changes need CSRF defenses. Rate limits should identify the correct principal and account for proxies.

Popular validation: go-playground/validator for tags; hand-written validation for explicit domain rules. Consider RFC 9457 Problem Details for error shape.

type Problem struct {
    Type   string `json:"type,omitempty"`
    Title  string `json:"title"`
    Status int    `json:"status"`
    Detail string `json:"detail,omitempty"`
}

func (in CreateUser) Validate() error {
    if !strings.Contains(in.Email, "@") {
        return errors.New("invalid email")
    }
    return nil
}
validation CORS CSRF rate limit errors
Data

database/sql connections, pools, and cancellation

sql.DB is a concurrent-safe connection pool, not one connection. Open usually does not verify connectivity; PingContext does.

Configure pool limits from database capacity and workload. Always close Rows, inspect rows.Err, and use Context methods. Transactions are bound to one connection. Prepared statements can help but drivers/pools affect their behavior.

Never concatenate untrusted SQL values; use parameters.

db, err := sql.Open("pgx", dsn)
if err != nil { return err }

db.SetMaxOpenConns(25)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute)

if err := db.PingContext(ctx); err != nil {
    return err
}

rows, err := db.QueryContext(ctx, `SELECT id, name FROM users WHERE active = $1`, true)
if err != nil { return err }
defer rows.Close()
database/sql pool SQL cancellation
Data

Transactions and consistency

A transaction groups operations with atomicity and isolation guarantees provided by the database.

Always defer rollback; a rollback after commit is harmless for database/sql. Keep transactions short, avoid network calls inside them, and understand isolation/anomaly requirements. Retrying serialization failures means retrying the whole transaction function.

For distributed workflows, consider outbox/inbox, idempotency, sagas, and durable queues rather than pretending a local DB transaction covers remote systems.

func transfer(ctx context.Context, db *sql.DB, from, to int64, cents int64) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil { return err }
    defer tx.Rollback()

    if _, err := tx.ExecContext(ctx,
        `UPDATE account SET balance = balance - $1 WHERE id = $2`,
        cents, from); err != nil {
        return err
    }
    if _, err := tx.ExecContext(ctx,
        `UPDATE account SET balance = balance + $1 WHERE id = $2`,
        cents, to); err != nil {
        return err
    }
    return tx.Commit()
}
transaction consistency isolation outbox
Data

PostgreSQL: pgx, sqlc, ORM, and migrations

For PostgreSQL, pgx exposes PostgreSQL-specific capabilities and can also back database/sql. sqlc generates typed Go code from SQL.

Common approaches: • pgx + sqlc: explicit SQL, strong types, generated boilerplate; excellent default when the team knows SQL. • database/sql + driver/sqlx: portable standard abstraction plus conveniences. • GORM/Ent/Bun: ORM or generated entity model when rapid CRUD/model tooling outweighs abstraction cost. • Squirrel/goqu: query builders for highly dynamic SQL.

Migrations: goose, golang-migrate, Atlas, or a deployment platform's migration step. Never run risky schema changes blindly on every application startup.

-- name: GetUser :one
SELECT id, email, created_at
FROM users
WHERE id = $1;

-- sqlc generates a typed method similar to:
-- func (q *Queries) GetUser(ctx context.Context, id int64) (User, error)

-- Useful:
-- github.com/jackc/pgx/v5
-- sqlc.dev
-- github.com/pressly/goose/v3
pgx sqlc ORM migration PostgreSQL
Data

Caching, Redis, and invalidation

Caching trades consistency and complexity for latency or load reduction. Invalidation and stampede control are the hard parts.

Patterns: cache-aside, write-through, request memoization, TTL, versioned keys, negative caching, singleflight. Cache only when measurements show value. Include tenant/security boundaries in keys. Treat Redis as a remote system with timeouts and failure policies.

Popular clients include go-redis and rueidis. x/sync/singleflight suppresses duplicate concurrent loads in one process.

v, err, _ := group.Do("user:"+id, func() (any, error) {
    if cached, ok := cache.Get(id); ok {
        return cached, nil
    }
    user, err := repo.Get(ctx, id)
    if err != nil { return nil, err }
    cache.Set(id, user, time.Minute)
    return user, nil
})
cache Redis singleflight invalidation
Data

Queues, events, and background jobs

Use durable brokers when work must survive process crashes or cross service boundaries.

Options depend on infrastructure: Kafka, NATS JetStream, RabbitMQ, cloud queues/pub-sub, Redis-backed job systems, or a transactional database outbox. Design for at-least-once delivery: handlers must be idempotent, acknowledge only after success, and send poison messages to a dead-letter path after bounded retries.

Carry event schema versions and stable IDs; monitor lag, retries, and age.

type Event struct {
    ID        string          `json:"id"`
    Type      string          `json:"type"`
    Version   int             `json:"version"`
    Occurred  time.Time       `json:"occurred_at"`
    Payload   json.RawMessage `json:"payload"`
}

// Consumer:
// 1. begin transaction
// 2. reject already-processed Event.ID
// 3. apply changes + record inbox ID
// 4. commit
// 5. acknowledge message
queue events idempotency outbox Kafka NATS
Testing

Unit tests, table tests, subtests, and helpers

The standard testing package is enough for most tests. Table tests make cases explicit and compact.

Use t.Helper in assertion helpers, t.Cleanup for resources, and t.Run for named subtests. Tests in package foo can access internals; package foo_test tests the public API. Avoid brittle tests of implementation details.

Run tests with randomized map iteration and consider -shuffle=on.

func TestParsePort(t *testing.T) {
    tests := []struct {
        name    string
        input   string
        want    int
        wantErr bool
    }{
        {"valid", "8080", 8080, false},
        {"invalid", "x", 0, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := ParsePort(tt.input)
            if (err != nil) != tt.wantErr {
                t.Fatalf("error=%v wantErr=%v", err, tt.wantErr)
            }
            if got != tt.want { t.Fatalf("got %d want %d", got, tt.want) }
        })
    }
}
testing table tests subtests
Testing

HTTP, database, integration, and golden tests

Use httptest for handlers/servers. Integration tests should exercise real boundaries where fakes would hide incompatibilities.

httptest.NewRecorder tests a handler in-process; httptest.NewServer tests an HTTP client. Testcontainers-go can launch real databases/brokers. Golden files work for stable large outputs; support an explicit update flag and review diffs.

Use build tags or test flags to separate expensive suites, but keep CI coverage clear.

func TestHealth(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/health", nil)
    rec := httptest.NewRecorder()

    handler.ServeHTTP(rec, req)

    if rec.Code != http.StatusOK {
        t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
    }
}

srv := httptest.NewServer(handler)
defer srv.Close()
httptest integration testcontainers golden files
Testing

Fuzzing, benchmarks, and examples

Go's toolchain includes coverage-guided fuzzing and benchmarks.

Fuzz parsers, validators, codecs, and boundary code. Seed representative inputs, then assert invariants and prevent excessive allocation/time. Benchmarks need representative setup, b.ResetTimer when setup is excluded, and b.ReportAllocs. Compare changes with benchstat rather than one noisy run.

Example functions can be compiled tests and documentation.

func FuzzRoundTrip(f *testing.F) {
    f.Add("hello")
    f.Fuzz(func(t *testing.T, s string) {
        encoded := Encode(s)
        decoded, err := Decode(encoded)
        if err != nil { t.Fatal(err) }
        if decoded != s { t.Fatalf("%q != %q", decoded, s) }
    })
}

func BenchmarkEncode(b *testing.B) {
    b.ReportAllocs()
    for b.Loop() {
        _ = Encode("representative input")
    }
}
fuzzing benchmark benchstat examples
Testing

Mocks, fakes, stubs, and dependency boundaries

Prefer small interfaces and hand-written fakes for domain tests. Generate mocks when interaction verification is genuinely useful.

A fake has working simplified behavior; a stub returns fixed responses; a mock records/validates calls. Excessive mocks couple tests to call sequences. Popular tools: GoMock, mockery, testify/mock. Standard library fakes such as httptest servers often test more realistically.

Inject clocks, random sources, repositories, and clients at boundaries—not every function.

type FakeStore struct {
    Items map[string]Item
    Err   error
}

func (f *FakeStore) Get(ctx context.Context, id string) (Item, error) {
    if f.Err != nil { return Item{}, f.Err }
    item, ok := f.Items[id]
    if !ok { return Item{}, ErrNotFound }
    return item, nil
}
mock fake dependency injection testability
Tooling

Modules, versions, workspaces, and private dependencies

go.mod records module path, language/toolchain requirements, and dependencies. go.sum authenticates downloaded module content.

Use minimal version selection semantics. go mod tidy adds needed and removes unused requirements. replace is useful locally but should not casually leak into released libraries. go.work coordinates multiple local modules without editing each go.mod.

For private modules configure GOPRIVATE and credentials; avoid broad disabling of proxy/checksum protections.

go mod init example.com/app
go get github.com/go-chi/chi/v5@latest
go mod tidy
go mod verify
go list -m -u all

go work init ./services/api ./libs/domain
go work use ./libs/telemetry

# Private modules:
go env -w GOPRIVATE=github.com/my-company/*
go.mod go.sum workspace dependencies
Tooling

Builds, tags, cross-compilation, cgo, and linking

Go usually produces a single native executable. GOOS/GOARCH select targets; build tags select files.

Pure-Go cross-compilation is straightforward. cgo integrates C but complicates cross-compilation, static linking, memory ownership, callbacks, and thread behavior. Prefer pure Go unless a native dependency is necessary.

Use -trimpath for reproducibility/privacy, ldflags -X for build metadata, and buildvcs metadata where appropriate.

# Linux AMD64 from another OS:
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -o bin/api ./cmd/api

# Build-tagged feature:
go build -tags=enterprise ./cmd/api

# Version injection:
go build -ldflags="-X main.version=v1.2.3" ./cmd/api
build cross compile cgo build tags linker
Tooling

Formatting, vetting, linting, and code generation

gofmt is non-negotiable. go vet finds suspicious constructs. Staticcheck and golangci-lint aggregate deeper checks.

Use gofmt/goimports in the editor. Keep lint configuration reviewed and version-pinned; enabling every linter creates noise. go generate runs explicit source-generation commands but is not run automatically by build/test.

Generated files should declare that they are generated and be reproducible.

gofmt -w .
goimports -w .
go vet ./...
staticcheck ./...
golangci-lint run

//go:generate go tool stringer -type=Status
type Status int
gofmt go vet staticcheck golangci-lint generate
Tooling

Profiling, tracing, escape analysis, and diagnostics

Measure before optimizing. Go includes CPU, heap, goroutine, block, mutex, execution trace, and runtime metrics.

Expose net/http/pprof only on a protected internal listener. Use go tool pprof for profiles and go tool trace for scheduler/latency analysis. Escape analysis reports why values move to the heap. GODEBUG exposes runtime diagnostics; use documented settings carefully.

Memory profiles show allocation/live-object behavior—not simply “memory leaks.” Goroutine profiles often reveal leaks.

go test -bench=. -benchmem -cpuprofile=cpu.out -memprofile=mem.out ./...
go tool pprof -http=:0 cpu.out
go test -trace=trace.out ./...
go tool trace trace.out

go build -gcflags="all=-m=2" ./cmd/api
go test -run=NONE -bench=. -blockprofile=block.out ./...
pprof trace escape analysis performance diagnostics
Tooling

Security and supply-chain checks

Keep Go and dependencies patched, minimize dependency surface, and verify what enters builds.

Use govulncheck to find known vulnerabilities reachable from your code. Review module provenance, licenses, release activity, and transitive dependencies. Pin CI tool versions and protect build credentials. Generate SBOMs and sign artifacts where your deployment environment requires it.

Treat deserialization, command execution, templates, SQL, paths, SSRF, redirects, and archive extraction as security boundaries.

govulncheck ./...
go mod verify
go list -m -json all
go version -m ./bin/api

# Also common in CI:
# go test -race ./...
# staticcheck ./...
# gosec ./...
security govulncheck supply chain SBOM
Ecosystem

HTTP routers and frameworks: decision guide

Start with net/http ServeMux. Add a router/framework for middleware ecosystems, binding conventions, or team familiarity.

Recommended categories: • net/http ServeMux: minimal dependencies, current method/path routing, maximum standard compatibility. • chi: idiomatic net/http composition, subrouters and middleware; strong standard-library-first choice. • Gin: batteries-included request context, binding, middleware ecosystem; common for API teams. • Echo: similarly full-featured with a conventional framework API. • Fiber: Express-like API built on fasthttp; choose only when its non-net/http trade-offs are understood. • Connect/gRPC: contract-first RPC rather than REST routing.

Benchmark your workload; router microbenchmarks rarely determine system throughput.

// Standard library:
mux.HandleFunc("POST /orders", createOrder)

// chi:
r := chi.NewRouter()
r.Use(middleware.RequestID, middleware.Recoverer)
r.Post("/orders", createOrder)

// Gin:
router := gin.New()
router.Use(gin.Recovery())
router.POST("/orders", createOrderGin)
router chi Gin Echo Fiber ServeMux
Ecosystem

Configuration and secrets

Configuration should be explicit, validated at startup, and separable from secrets.

Standard library + env flags is often enough. Popular packages: • caarlos0/env: typed environment decoding. • koanf: composable sources with a small core. • Viper: broad formats/sources and Cobra integration, but more global/magic behavior. • envconfig: simple env-to-struct mapping.

Do not silently accept invalid defaults. Secrets should come from environment/secret managers/files with restricted permissions, never committed configuration.

type Config struct {
    Addr     string
    Database string
    Timeout  time.Duration
}

func LoadConfig() (Config, error) {
    cfg := Config{
        Addr:    envOr("ADDR", ":8080"),
        Timeout: 5 * time.Second,
        Database: os.Getenv("DATABASE_URL"),
    }
    if cfg.Database == "" {
        return Config{}, errors.New("DATABASE_URL is required")
    }
    return cfg, nil
}
config Viper koanf secrets environment
Ecosystem

CLI applications

The standard flag package is good for small commands. Cobra is common for nested commands and rich UX.

Alternatives include urfave/cli and Kong. Separate command parsing from application logic, return errors instead of calling os.Exit deep inside code, and direct stdout/stderr intentionally. Add shell completion only when useful.

For terminal UI, Bubble Tea is a popular model-update-view framework.

func run(args []string, stdout, stderr io.Writer) error {
    fs := flag.NewFlagSet("serve", flag.ContinueOnError)
    fs.SetOutput(stderr)
    addr := fs.String("addr", ":8080", "listen address")
    if err := fs.Parse(args); err != nil {
        return err
    }
    fmt.Fprintln(stdout, "listening on", *addr)
    return nil
}
CLI Cobra flag terminal UI
Ecosystem

Observability: metrics, traces, and logs

Use logs for events, metrics for aggregate trends/alerts, and traces for request paths across components.

OpenTelemetry is the vendor-neutral tracing/metrics API and SDK ecosystem. Prometheus client_golang is common for pull-based metrics. slog handles structured logs; bridges/exporters can correlate trace IDs.

Instrument inbound/outbound HTTP, database calls, queues, and critical domain operations. Control cardinality: never use user IDs or raw URLs as metric labels.

meter := otel.Meter("inventory")
requests, _ := meter.Int64Counter("http.server.requests")

func record(ctx context.Context, method, route string) {
    requests.Add(ctx, 1,
        metric.WithAttributes(
            attribute.String("http.request.method", method),
            attribute.String("http.route", route),
        ),
    )
}
OpenTelemetry Prometheus metrics tracing observability
Ecosystem

Dependency injection and application wiring

Manual constructors are idiomatic and transparent. A composition root wires concrete infrastructure to interfaces.

For large compile-time graphs, Google Wire generates wiring code. Runtime containers such as Uber Fx/Dig can manage lifecycle but add indirection. Do not introduce a container merely to avoid writing constructors.

Functional options are useful for optional construction parameters; avoid making required dependencies optional.

type Service struct {
    repo   Repository
    logger *slog.Logger
}

func NewService(repo Repository, logger *slog.Logger) *Service {
    if repo == nil { panic("nil repository") }
    if logger == nil { logger = slog.Default() }
    return &Service{repo: repo, logger: logger}
}

func buildApp(cfg Config) (*App, error) {
    db, err := openDB(cfg)
    if err != nil { return nil, err }
    repo := postgres.NewRepository(db)
    svc := order.NewService(repo, slog.Default())
    return NewApp(svc, db), nil
}
dependency injection Wire Fx constructors
Ecosystem

API schemas and code generation

Schema-first tooling keeps clients and servers aligned but introduces generation workflows.

REST: OpenAPI with oapi-codegen, ogen, or Goa. RPC: Protocol Buffers with gRPC or Connect; Buf manages linting, breaking checks, and generation. GraphQL: gqlgen is a common schema-first server. SQL: sqlc generates query code. Mocks/enums: mockery/GoMock/stringer.

Pin generators, run them reproducibly, and decide whether generated output is committed.

# Example tool pattern in Go 1.24+ modules:
go get -tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
go tool oapi-codegen -config cfg.yaml api/openapi.yaml

# Protobuf ecosystem:
buf lint
buf breaking --against '.git#branch=main'
buf generate
OpenAPI oapi-codegen protobuf Buf GraphQL codegen
Ecosystem

Useful golang.org/x packages

The x repositories contain widely used packages maintained alongside the Go project but versioned outside the standard library.

Frequently useful: • x/sync: errgroup, semaphore, singleflight. • x/time/rate: token-bucket rate limiter. • x/oauth2: OAuth 2.0 clients. • x/crypto: bcrypt, argon2, ssh, advanced crypto. • x/net: HTTP/2 helpers, proxy, net utilities. • x/text: encodings, Unicode transforms, localization. • x/exp: experimental APIs—adopt with care. • x/tools: analysis and developer tools. • x/sys: OS-level interfaces.

import (
    "golang.org/x/sync/errgroup"
    "golang.org/x/sync/singleflight"
    "golang.org/x/time/rate"
)

limiter := rate.NewLimiter(rate.Limit(20), 40)
if !limiter.Allow() {
    return ErrRateLimited
}
golang.org/x errgroup rate oauth2 crypto
Ecosystem

Recommended production stack templates

Use the lightest stack that matches the project. These are coherent starting points, not universal winners.

Lean API: net/http ServeMux or chi + slog + pgx/sqlc + goose + OpenTelemetry + standard testing.

Productive conventional API: Gin or Echo + validator + pgx/GORM + JWT/OIDC library + testify + OpenTelemetry.

Contract-first services: Connect or gRPC + protobuf/Buf + pgx/sqlc + OTel + Kubernetes/cloud deployment.

Server-rendered product: chi/net/http + templ/html/template + htmx + pgx/sqlc + go:embed.

SPA: Go JSON API + OIDC/session auth + Vite React/Vue/Svelte build + embedded dist for simple deployments or CDN for independent frontend releases.

# Lean service dependency sketch:
github.com/go-chi/chi/v5
github.com/jackc/pgx/v5
github.com/pressly/goose/v3
go.opentelemetry.io/otel
golang.org/x/sync

# Add dependencies only when used:
go get package@version
go mod tidy
stack architecture recommendation
Production

Graceful startup, shutdown, and signal handling

A production process should fail fast on invalid configuration and shut down in a bounded, ordered manner.

Use signal.NotifyContext for SIGINT/SIGTERM. Stop accepting traffic, allow in-flight requests to finish, stop producers, drain workers where safe, flush telemetry, and close pools. Give shutdown a deadline and return non-zero on startup/runtime failure.

Readiness should become false before shutdown begins in orchestrated environments.

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

errCh := make(chan error, 1)
go func() { errCh <- srv.ListenAndServe() }()

select {
case err := <-errCh:
    if !errors.Is(err, http.ErrServerClosed) { return err }
case <-ctx.Done():
}

shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
shutdown signals lifecycle readiness
Production

Health, readiness, resilience, and overload

Health endpoints should reflect actionable process and dependency states without creating extra load.

Liveness: is the process stuck and should it restart? Readiness: should it receive traffic? Startup: has initialization completed?

Use timeouts everywhere, retries selectively, circuit breaking when it improves failure containment, bulkheads/concurrency limits, and load shedding. Avoid retry storms: combine deadlines, bounded attempts, backoff, jitter, and budgets.

mux.HandleFunc("GET /livez", func(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
})

mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
    if !ready.Load() {
        http.Error(w, "not ready", http.StatusServiceUnavailable)
        return
    }
    w.WriteHeader(http.StatusOK)
})
health readiness resilience overload circuit breaker
Production

Containers, deployment, and static binaries

Go binaries fit small container images, but operational correctness matters more than image size.

Build in one stage and copy into a minimal runtime. Include CA certificates and timezone data when needed, run as non-root, use read-only filesystems where feasible, and expose no debug endpoints publicly. Distroless/scratch require knowing which runtime files your app uses.

Cross-compiled CGO-disabled binaries are easiest; cgo needs compatible runtime libraries.

# syntax=docker/dockerfile:1
FROM golang:1.26 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /out/api ./cmd/api

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/api /api
USER nonroot:nonroot
ENTRYPOINT ["/api"]
Docker deployment static binary container
Production

Performance mental model

Optimize end-to-end bottlenecks: algorithms, network/database calls, contention, allocation rate, serialization, and cache behavior.

Slices/maps/pointers may allocate when values escape. Interfaces and closures can contribute allocations but should be measured. Preallocate when sizes are known, reuse buffers cautiously, stream large data, batch I/O, reduce lock contention, and tune pools against downstream capacity.

Do not sacrifice correctness/readability for speculative micro-optimizations. Benchmark representative workloads and validate with profiles.

items := make([]Item, 0, len(rows))
for _, row := range rows {
    items = append(items, convert(row))
}

var buf bytes.Buffer
buf.Grow(estimatedSize)
enc := json.NewEncoder(&buf)
_ = enc.Encode(payload)

// Then confirm:
// go test -bench=. -benchmem
// go tool pprof
performance allocations contention benchmark
Production

Common Go pitfalls checklist

Most production bugs come from ownership, lifetimes, ignored errors, and incorrect assumptions—not syntax.

Review: • goroutine has an exit path and bounded concurrency. • response/request bodies, rows, files, timers, and tickers are closed/stopped. • errors preserve causes and are not double-logged. • no unsynchronized map/slice/shared state. • loop captures and addresses refer to intended values. • nil interface vs typed nil handled. • append result assigned; backing-array aliasing understood. • HTTP clients/servers have timeouts. • database rows.Err and transaction rollback checked. • contexts propagate and cancel functions are called. • secrets are not logged. • JSON/body size bounded. • map order is never relied upon. • copies do not duplicate mutexes. • tests run with race detector and critical integrations.

# High-value pre-merge commands:
go fmt ./...
go vet ./...
go test ./...
go test -race ./...
staticcheck ./...
govulncheck ./...
pitfalls checklist review
Production

Idiomatic design principles

Go code scales through simplicity, explicit dependencies, small interfaces, and clear ownership.

Prefer: • clarity over cleverness. • concrete types until abstraction is needed. • errors as values with context. • composition over inheritance-style frameworks. • standard library compatibility. • synchronous code first; concurrency only for a reason. • immutable snapshots or owned mutation. • package APIs that make invalid use difficult. • boring deployment and observable failure.

“Do not communicate by sharing memory; share memory by communicating” is guidance, not a ban on mutexes. Choose the primitive that makes ownership and invariants clearest.

type Repository interface {
    Save(context.Context, Order) error
}

type Service struct {
    repo Repository
}

func NewService(repo Repository) *Service {
    return &Service{repo: repo}
}
idioms design ownership interfaces
No topics match the current search and category filter.