Tech Guides
01

Quick Reference

Essential Go commands, declaration patterns, and common idioms at a glance.

Common Go Commands

Command Description
go run main.go Compile and execute a Go program in one step
go build Compile the current package into an executable binary
go test ./... Run all tests recursively in the current module
go fmt ./... Format all Go source files with canonical style
go vet ./... Report suspicious constructs and likely bugs
go mod init module/path Initialize a new module in the current directory
go mod tidy Add missing and remove unused module dependencies
go get pkg@version Add or update a dependency in go.mod
go install pkg@latest Build and install a Go binary to $GOBIN
go doc fmt.Println Show documentation for a symbol or package
go generate ./... Run code generation directives in source files
go work init Initialize a multi-module workspace (Go 1.18+)

Variable Declaration Quick Reference

Short Declaration
name := "gopher"

Infers type. Only inside functions. Most common form.

Var Declaration
var count int = 10

Explicit type. Works at package level and inside functions.

Zero Value Var
var active bool

Declares with zero value (false for bool, 0 for int, "" for string).

Constants
const Pi = 3.14159

Compile-time constant. Untyped unless explicitly typed.

Iota Enums
const ( A = iota; B; C )

Auto-incrementing integer constants: A=0, B=1, C=2.

Multiple Assign
x, y := 10, "hello"

Declare multiple variables at once with different types.

Common Patterns

Error Check

result, err := doSomething()
if err != nil {
    return fmt.Errorf("failed: %w", err)
}

The canonical Go error-handling pattern. Check errors immediately after every fallible call.

Goroutine Launch

var wg sync.WaitGroup
wg.Add(1)
go func() {
    defer wg.Done()
    process(item)
}()
wg.Wait()

Launch concurrent work with a WaitGroup to synchronize completion.

Defer Close

f, err := os.Open("data.txt")
if err != nil {
    return err
}
defer f.Close()
// use f...

Defer cleanup immediately after acquiring a resource. Deferred calls run in LIFO order when the function returns.

Struct Literal

srv := &http.Server{
    Addr:         ":8080",
    ReadTimeout:  5 * time.Second,
    WriteTimeout: 10 * time.Second,
}

Initialize structs with named fields. Unset fields receive their zero value.

Slice Operations

items := []string{"a", "b", "c"}
items = append(items, "d")
sub := items[1:3] // ["b", "c"]
cp := make([]string, len(items))
copy(cp, items)

Slices are Go's dynamic arrays. Append grows, slicing shares memory, copy for safe duplication.

Map Operations

m := map[string]int{"a": 1, "b": 2}
m["c"] = 3
val, ok := m["a"]  // ok == true
delete(m, "b")
for k, v := range m { ... }

Maps are hash tables. Always use the comma-ok idiom to distinguish missing keys from zero values.

02

Core Syntax

Fundamental building blocks of the Go language: packages, types, variables, and control flow.

Package Structure & Imports

Every Go file begins with a package declaration. The main package with a main() function is the entry point for executables. All other packages are libraries.

package main

import (
    "fmt"
    "net/http"
    "os"

    "github.com/gorilla/mux"          // third-party import
    "mymodule/internal/auth"           // internal package
)

func main() {
    fmt.Println("Hello, Go!")
}
Import Conventions Group imports in three blocks separated by blank lines: standard library, third-party packages, and local/internal packages. The goimports tool handles this automatically.
// Package-level declarations (visible across all files in the package)
package config

var DefaultPort = 8080            // exported (uppercase)
var defaultHost = "localhost"     // unexported (lowercase)

// init() runs once when the package is first imported
func init() {
    if port := os.Getenv("PORT"); port != "" {
        DefaultPort, _ = strconv.Atoi(port)
    }
}

Variable Declarations

// Short variable declaration (inside functions only)
name := "gopher"
x, y := 10, 20

// Var declaration (works at package level too)
var age int = 30
var city string                   // zero value: ""
var (
    width  float64 = 1920
    height float64 = 1080
)

// Constants
const Pi = 3.14159265             // untyped constant
const MaxRetries int = 3          // typed constant

// Iota — auto-incrementing constant generator
const (
    Sunday    = iota              // 0
    Monday                        // 1
    Tuesday                       // 2
    Wednesday                     // 3
    Thursday                      // 4
    Friday                        // 5
    Saturday                      // 6
)

// Iota with expressions
const (
    _  = iota                     // skip 0
    KB = 1 << (10 * iota)        // 1024
    MB                            // 1048576
    GB                            // 1073741824
    TB                            // 1099511627776
)
Tip Prefer := inside functions for brevity. Use var when you need to specify a type explicitly, declare at package level, or want the zero value without assigning.

Basic Types & Zero Values

Type Zero Value Description
bool false Boolean true/false
int, int8 ... int64 0 Signed integers (int is platform-dependent: 32 or 64 bit)
uint, uint8 ... uint64 0 Unsigned integers; byte is alias for uint8
float32, float64 0 IEEE-754 floating point; prefer float64
complex64, complex128 (0+0i) Complex numbers with float32/float64 parts
string "" Immutable UTF-8 byte sequence
rune 0 Alias for int32; represents a Unicode code point
uintptr 0 Integer large enough to hold any pointer value

Composite Types

// ── Arrays (fixed size, rarely used directly) ──
var grid [3][3]int                   // 3x3 matrix of zeros
primes := [5]int{2, 3, 5, 7, 11}

// ── Slices (dynamic, backed by an array) ──
nums := []int{1, 2, 3, 4, 5}
nums = append(nums, 6, 7)           // grow slice
sub := nums[2:5]                     // [3, 4, 5] — shares memory
fresh := make([]int, 0, 100)        // len=0, cap=100

// ── Maps (hash table) ──
scores := map[string]int{
    "alice": 95,
    "bob":   87,
}
scores["carol"] = 91
val, ok := scores["dave"]           // ok is false, val is 0
delete(scores, "bob")

// ── Structs ──
type Point struct {
    X, Y float64
}

type User struct {
    Name    string `json:"name"`
    Email   string `json:"email"`
    Age     int    `json:"age,omitempty"`
    admin   bool   // unexported field
}

p := Point{X: 3.0, Y: 4.0}
u := User{Name: "Alice", Email: "alice@example.com"}

// Struct embedding (composition over inheritance)
type Admin struct {
    User                              // embedded struct
    Level int
}
a := Admin{User: User{Name: "Bob"}, Level: 1}
fmt.Println(a.Name)                  // promoted field

Pointers

Go has pointers but no pointer arithmetic. Pointers are used to share data and avoid copying large structures.

x := 42
p := &x                              // p is *int, points to x
fmt.Println(*p)                      // 42 — dereference
*p = 100                             // x is now 100

// new() allocates and returns a pointer
ptr := new(int)                      // *int, points to 0

// Structs are commonly used via pointers
func newUser(name string) *User {
    return &User{Name: name}         // safe: Go escapes to heap
}

// Nil pointer — the zero value for pointer types
var np *int                          // nil
if np != nil {
    fmt.Println(*np)
}
Warning Dereferencing a nil pointer causes a runtime panic. Always check for nil when a pointer could be unset, especially with function return values and struct fields.

Control Flow

// ── If / Else (no parentheses, braces required) ──
if x > 10 {
    fmt.Println("big")
} else if x > 5 {
    fmt.Println("medium")
} else {
    fmt.Println("small")
}

// If with init statement
if err := validate(input); err != nil {
    return err                        // err scoped to this if/else
}

// ── For (the only loop keyword) ──
for i := 0; i < 10; i++ {           // classic C-style
    fmt.Println(i)
}

for condition {                      // while-style
    // ...
}

for {                                // infinite loop
    break                            // exit
}

// ── Range (iterate slices, maps, strings, channels) ──
for i, v := range []string{"a", "b", "c"} {
    fmt.Printf("%d: %s\n", i, v)
}

for key, val := range myMap {        // map iteration (random order)
    fmt.Printf("%s = %d\n", key, val)
}

for i := range 10 {                 // Go 1.22+ range over integer
    fmt.Println(i)                   // 0, 1, 2, ... 9
}

// ── Switch (no fall-through by default) ──
switch day {
case "Monday", "Tuesday":
    fmt.Println("early week")
case "Friday":
    fmt.Println("TGIF")
    fallthrough                      // explicit fall-through
default:
    fmt.Println("another day")
}

// Type switch
switch v := x.(type) {
case int:
    fmt.Printf("int: %d\n", v)
case string:
    fmt.Printf("string: %s\n", v)
default:
    fmt.Printf("unknown: %T\n", v)
}

// ── Defer, Panic, Recover ──
func safeDiv(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic: %v", r)
        }
    }()
    return a / b, nil                // panics if b == 0
}
Range Over Integers Go 1.22 introduced for i := range n, which iterates from 0 to n-1. This replaces the verbose for i := 0; i < n; i++ pattern for simple counting loops.
03

Functions & Interfaces

Functions, methods, closures, interfaces, and generics: how Go composes behavior.

Function Syntax

// Basic function
func add(a, b int) int {
    return a + b
}

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

// Named return values (useful for documentation, short functions)
func coordinates(addr string) (lat, lng float64, err error) {
    // ... geocoding logic ...
    return lat, lng, nil
}

// Blank identifier to ignore return values
_, err := divide(10, 0)

// Functions are first-class values
var op func(int, int) int = add
result := op(3, 4)                   // 7

Variadic Functions

// Variadic parameter (must be last parameter)
func sum(nums ...int) int {
    total := 0
    for _, n := range nums {
        total += n
    }
    return total
}

sum(1, 2, 3)                         // 6
sum()                                // 0

// Spread a slice into variadic args
values := []int{10, 20, 30}
sum(values...)                       // 60

// Common stdlib example
fmt.Println("a", "b", "c")          // Println(a ...any)

Methods: Value vs Pointer Receivers

type Rect struct {
    Width, Height float64
}

// Value receiver — operates on a copy
func (r Rect) Area() float64 {
    return r.Width * r.Height
}

// Pointer receiver — can modify the original
func (r *Rect) Scale(factor float64) {
    r.Width *= factor
    r.Height *= factor
}

r := Rect{Width: 10, Height: 5}
fmt.Println(r.Area())                // 50
r.Scale(2)
fmt.Println(r.Area())                // 200
Receiver Guidelines Use a pointer receiver when the method mutates the receiver, the struct is large (avoids copying), or for consistency if any method on the type uses a pointer receiver. Use a value receiver for small, immutable types.

Closures

// A closure captures variables from its enclosing scope
func makeCounter() func() int {
    count := 0
    return func() int {
        count++
        return count
    }
}

counter := makeCounter()
fmt.Println(counter())               // 1
fmt.Println(counter())               // 2
fmt.Println(counter())               // 3

// Closures are often used with goroutines and callbacks
func processItems(items []string, fn func(string)) {
    for _, item := range items {
        fn(item)
    }
}

processItems([]string{"a", "b"}, func(s string) {
    fmt.Println(strings.ToUpper(s))
})

Interfaces & Implicit Implementation

Interfaces in Go are satisfied implicitly. If a type has all the methods an interface requires, it implements that interface. No implements keyword needed.

// Define an interface
type Shape interface {
    Area() float64
    Perimeter() float64
}

// Circle implicitly implements Shape
type Circle struct {
    Radius float64
}

func (c Circle) Area() float64 {
    return math.Pi * c.Radius * c.Radius
}

func (c Circle) Perimeter() float64 {
    return 2 * math.Pi * c.Radius
}

// Use the interface
func printInfo(s Shape) {
    fmt.Printf("Area: %.2f, Perimeter: %.2f\n", s.Area(), s.Perimeter())
}

printInfo(Circle{Radius: 5})         // works!

// The empty interface accepts any value (aliased as "any" since Go 1.18)
func describe(val any) {
    fmt.Printf("(%v, %T)\n", val, val)
}

describe(42)                          // (42, int)
describe("hello")                     // (hello, string)

Type Assertions & Type Switches

// Type assertion — extract the concrete type
var i any = "hello"

s := i.(string)                      // panics if i is not string
fmt.Println(s)                       // "hello"

s, ok := i.(string)                  // safe form — ok is false if wrong type
if ok {
    fmt.Println(s)
}

// Type switch — branch on dynamic type
func classify(val any) string {
    switch v := val.(type) {
    case nil:
        return "nil"
    case int:
        return fmt.Sprintf("int: %d", v)
    case string:
        return fmt.Sprintf("string of len %d", len(v))
    case bool:
        return fmt.Sprintf("bool: %t", v)
    case error:
        return fmt.Sprintf("error: %s", v)
    default:
        return fmt.Sprintf("unknown: %T", v)
    }
}

Common Standard Library Interfaces

Interface Method(s) Purpose
io.Reader Read(p []byte) (n int, err error) Reads bytes from a source (files, network, buffers)
io.Writer Write(p []byte) (n int, err error) Writes bytes to a destination
io.Closer Close() error Releases resources (files, connections)
fmt.Stringer String() string Custom string representation for fmt.Print
error Error() string The built-in error interface
sort.Interface Len(), Less(i,j), Swap(i,j) Custom sorting for any collection
http.Handler ServeHTTP(w, *r) Handle HTTP requests
encoding.TextMarshaler MarshalText() ([]byte, error) Custom text serialization
// Implementing fmt.Stringer
type Point struct{ X, Y float64 }

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

fmt.Println(Point{3, 4})            // prints: (3, 4)

// Implementing io.Reader
type InfiniteZeros struct{}

func (z InfiniteZeros) Read(p []byte) (int, error) {
    for i := range p {
        p[i] = 0
    }
    return len(p), nil
}

Generics Go 1.18+

Go 1.18 introduced type parameters, enabling functions and types to operate on multiple concrete types while preserving type safety.

// Generic function with type parameter
func Map[T, U any](slice []T, fn func(T) U) []U {
    result := make([]U, len(slice))
    for i, v := range slice {
        result[i] = fn(v)
    }
    return result
}

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

// Type constraint — restrict what types are allowed
func Min[T constraints.Ordered](a, b T) T {
    if a < b {
        return a
    }
    return b
}

Min(3, 7)                            // 3
Min("alpha", "beta")                 // "alpha"

// Custom constraint interface
type Number interface {
    ~int | ~int32 | ~int64 | ~float32 | ~float64
}

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

// The comparable constraint — types that support == and !=
func Contains[T comparable](slice []T, target T) bool {
    for _, v := range slice {
        if v == target {
            return true
        }
    }
    return false
}

// Generic types
type Stack[T any] struct {
    items []T
}

func (s *Stack[T]) Push(v T) {
    s.items = append(s.items, v)
}

func (s *Stack[T]) Pop() (T, bool) {
    if len(s.items) == 0 {
        var zero T
        return zero, false
    }
    v := s.items[len(s.items)-1]
    s.items = s.items[:len(s.items)-1]
    return v, true
}
The ~ Tilde Operator The ~int constraint matches int and any named type whose underlying type is int (e.g., type UserID int). Without the tilde, only the exact type matches.
04

Concurrency

Goroutines, channels, select, context, and sync primitives: Go's concurrency model built on CSP.

Goroutines & the go Keyword

A goroutine is a lightweight thread of execution managed by the Go runtime. Goroutines start with as little as 2 KB of stack space and are multiplexed onto OS threads.

// Launch a goroutine
go func() {
    fmt.Println("running concurrently")
}()

// Named function as goroutine
go processRequest(req)

// Goroutines are cheap — launch thousands
for i := 0; i < 10_000; i++ {
    go worker(i)
}
Warning The main goroutine does not wait for other goroutines. If main() returns, all goroutines are terminated. Use sync.WaitGroup, channels, or select{} to keep main alive until work completes.

WaitGroup

import "sync"

func main() {
    var wg sync.WaitGroup

    urls := []string{
        "https://example.com",
        "https://golang.org",
        "https://go.dev",
    }

    for _, url := range urls {
        wg.Add(1)
        go func(u string) {
            defer wg.Done()
            resp, err := http.Get(u)
            if err != nil {
                log.Println(err)
                return
            }
            defer resp.Body.Close()
            fmt.Printf("%s: %s\n", u, resp.Status)
        }(url)
    }

    wg.Wait()  // blocks until all goroutines call Done()
    fmt.Println("All requests complete")
}

Channels

// ── Unbuffered channel (synchronous) ──
ch := make(chan string)

go func() {
    ch <- "hello"                    // blocks until receiver is ready
}()

msg := <-ch                         // blocks until sender sends
fmt.Println(msg)                     // "hello"

// ── Buffered channel (asynchronous up to capacity) ──
jobs := make(chan int, 100)          // buffer of 100
jobs <- 42                          // doesn't block (buffer not full)
val := <-jobs                       // 42

// ── Directional channels (for function signatures) ──
func producer(out chan<- int) {      // send-only
    for i := 0; i < 5; i++ {
        out <- i
    }
    close(out)
}

func consumer(in <-chan int) {       // receive-only
    for val := range in {            // iterates until channel closed
        fmt.Println(val)
    }
}

ch := make(chan int)
go producer(ch)
consumer(ch)

// ── Closing channels ──
close(ch)                            // signal no more values
val, ok := <-ch                     // ok is false after close

// Range over channel (exits when channel is closed)
for item := range ch {
    process(item)
}
Channel Axioms A send to a nil channel blocks forever. A receive from a nil channel blocks forever. A send to a closed channel panics. A receive from a closed channel returns the zero value immediately. Close only on the sender side.

Select Statement

Select lets a goroutine wait on multiple channel operations simultaneously. It blocks until one case is ready; if multiple are ready, one is chosen at random.

// ── Basic select ──
select {
case msg := <-msgCh:
    fmt.Println("received:", msg)
case errCh <- err:
    fmt.Println("sent error")
case <-done:
    fmt.Println("shutting down")
    return
}

// ── Select with default (non-blocking) ──
select {
case msg := <-ch:
    handle(msg)
default:
    // ch isn't ready, do something else
    fmt.Println("no message available")
}

// ── Select with timeout ──
select {
case result := <-ch:
    fmt.Println("got result:", result)
case <-time.After(3 * time.Second):
    fmt.Println("timed out after 3s")
}

// ── Ticker loop with quit channel ──
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()

for {
    select {
    case t := <-ticker.C:
        fmt.Println("tick at", t)
    case <-quit:
        return
    }
}

Context Package

The context package carries deadlines, cancellation signals, and request-scoped values across API boundaries and goroutines.

import "context"

// ── WithCancel — manual cancellation ──
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

go func(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("cancelled:", ctx.Err())
            return
        default:
            doWork()
        }
    }
}(ctx)

cancel()  // signal all goroutines using this context

// ── WithTimeout — automatic cancellation after duration ──
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

result, err := longOperation(ctx)
if err == context.DeadlineExceeded {
    log.Println("operation timed out")
}

// ── WithDeadline — cancel at specific time ──
deadline := time.Now().Add(30 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

// ── WithValue — attach request-scoped data ──
type contextKey string
const userKey contextKey = "user"

ctx = context.WithValue(ctx, userKey, currentUser)

// Retrieve later
if user, ok := ctx.Value(userKey).(*User); ok {
    fmt.Println("request from:", user.Name)
}
Context Best Practices Always pass context as the first parameter: func DoThing(ctx context.Context, ...). Never store context in a struct. Always call the cancel function (use defer). Do not use context.WithValue for passing optional parameters; use it only for request-scoped data like trace IDs.

Sync Primitives

import "sync"

// ── Mutex — mutual exclusion ──
type SafeCounter struct {
    mu sync.Mutex
    v  map[string]int
}

func (c *SafeCounter) Inc(key string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.v[key]++
}

func (c *SafeCounter) Value(key string) int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.v[key]
}

// ── RWMutex — multiple readers, single writer ──
type Cache struct {
    mu   sync.RWMutex
    data map[string]string
}

func (c *Cache) Get(key string) (string, bool) {
    c.mu.RLock()                     // multiple readers allowed
    defer c.mu.RUnlock()
    val, ok := c.data[key]
    return val, ok
}

func (c *Cache) Set(key, val string) {
    c.mu.Lock()                      // exclusive write access
    defer c.mu.Unlock()
    c.data[key] = val
}

// ── Once — exactly one execution ──
var once sync.Once
var instance *Database

func GetDB() *Database {
    once.Do(func() {
        instance = connectToDatabase()
    })
    return instance
}

// ── Pool — reusable object pool ──
var bufPool = sync.Pool{
    New: func() any {
        return new(bytes.Buffer)
    },
}

buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
// use buf ...
bufPool.Put(buf)                     // return to pool

Atomic Operations

import "sync/atomic"

// ── Atomic counter (lock-free) ──
var counter atomic.Int64

counter.Add(1)                       // increment
counter.Add(-1)                      // decrement
val := counter.Load()                // read
counter.Store(0)                     // write
swapped := counter.CompareAndSwap(5, 10)  // CAS

// ── Atomic value (store any type) ──
var config atomic.Value

type Config struct {
    Workers int
    Debug   bool
}

config.Store(Config{Workers: 4, Debug: false})
cfg := config.Load().(Config)

// ── Atomic bool ──
var ready atomic.Bool
ready.Store(true)
if ready.Load() {
    proceed()
}

Common Concurrency Patterns

Worker Pool

func workerPool(numWorkers int, jobs <-chan Job, results chan<- Result) {
    var wg sync.WaitGroup
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobs {
                results <- process(job)
            }
        }()
    }
    wg.Wait()
    close(results)
}

Fixed pool of goroutines consuming from a shared job channel. Controls resource usage.

Fan-In (Merge)

func fanIn(channels ...<-chan string) <-chan string {
    merged := make(chan string)
    var wg sync.WaitGroup
    for _, ch := range channels {
        wg.Add(1)
        go func(c <-chan string) {
            defer wg.Done()
            for val := range c {
                merged <- val
            }
        }(ch)
    }
    go func() {
        wg.Wait()
        close(merged)
    }()
    return merged
}

Merge multiple channels into one. Collects results from parallel producers.

Pipeline

func generate(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * n
        }
        close(out)
    }()
    return out
}

// Usage: compose stages
for v := range square(square(generate(2, 3))) {
    fmt.Println(v)  // 16, 81
}

Chain processing stages via channels. Each stage reads from the previous and writes to the next.

Semaphore

// Limit concurrent operations
sem := make(chan struct{}, 10) // max 10

for _, url := range urls {
    sem <- struct{}{}  // acquire
    go func(u string) {
        defer func() { <-sem }() // release
        fetch(u)
    }(url)
}

// Fill semaphore to wait for all
for i := 0; i < cap(sem); i++ {
    sem <- struct{}{}
}

Buffered channel as a counting semaphore. Limits concurrency to N simultaneous operations.

Common Gotchas

Bug: Loop Variable Capture (pre Go 1.22)
for _, url := range urls {
    go func() {
        fetch(url) // all goroutines
                   // see last url!
    }()
}
Fix: Pass as Argument
for _, url := range urls {
    go func(u string) {
        fetch(u)   // each gets
                   // its own copy
    }(url)
}
Go 1.22 Loop Variable Fix Starting in Go 1.22, loop variables are per-iteration, not per-loop. The left-side bug is fixed automatically in Go 1.22+. However, passing values as function arguments remains a clear, explicit pattern that works in all versions.
Bug: Goroutine Leak
ch := make(chan int)
go func() {
    result := compute()
    ch <- result // blocks forever
               // if nobody reads ch
}()
// forgot to <-ch
Fix: Use Context for Cancellation
ctx, cancel := context.WithTimeout(
    context.Background(),
    5*time.Second,
)
defer cancel()
select {
case result := <-ch:
    use(result)
case <-ctx.Done():
    log.Println("timed out")
}
05

Error Handling

Go treats errors as values. Master the error interface, wrapping, sentinel errors, and best practices.

The error Interface

In Go, errors are ordinary values that implement the built-in error interface. There are no exceptions, no try/catch. Errors are returned and checked explicitly.

// The built-in error interface
type error interface {
    Error() string
}

// Functions return errors as the last return value
func readFile(path string) ([]byte, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }
    return data, nil
}

// Callers check errors immediately
data, err := readFile("config.yaml")
if err != nil {
    log.Fatalf("cannot read config: %v", err)
}

Creating Errors: errors.New and fmt.Errorf

import (
    "errors"
    "fmt"
)

// Simple static error message
err := errors.New("connection refused")

// Formatted error with context
err := fmt.Errorf("user %q not found in database", username)

// Wrapping an underlying error with %w (Go 1.13+)
data, err := os.ReadFile(path)
if err != nil {
    return fmt.Errorf("loading config %s: %w", path, err)
}
// The resulting error chain:
// "loading config app.yaml: open app.yaml: no such file or directory"
%w vs %v Use %w to wrap an error so callers can unwrap and inspect it with errors.Is or errors.As. Use %v when you want to include the error text but intentionally break the chain so callers cannot inspect the underlying error.

errors.Is, errors.As, errors.Unwrap

// ── errors.Is — check for a specific error value in the chain ──
if errors.Is(err, os.ErrNotExist) {
    fmt.Println("file does not exist")
}

if errors.Is(err, context.DeadlineExceeded) {
    fmt.Println("operation timed out")
}

// errors.Is walks the entire wrap chain
wrapped := fmt.Errorf("outer: %w", fmt.Errorf("inner: %w", os.ErrPermission))
errors.Is(wrapped, os.ErrPermission) // true

// ── errors.As — extract a specific error type from the chain ──
var pathErr *os.PathError
if errors.As(err, &pathErr) {
    fmt.Println("failed path:", pathErr.Path)
    fmt.Println("operation:", pathErr.Op)
}

var netErr *net.OpError
if errors.As(err, &netErr) {
    fmt.Println("net op:", netErr.Op)
}

// ── errors.Unwrap — get the next error in the chain ──
inner := errors.Unwrap(err)          // nil if not wrapped

Custom Error Types

// Custom error type with structured data
type ValidationError struct {
    Field   string
    Message string
    Value   any
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on %s: %s (got %v)",
        e.Field, e.Message, e.Value)
}

func validateAge(age int) error {
    if age < 0 || age > 150 {
        return &ValidationError{
            Field:   "age",
            Message: "must be between 0 and 150",
            Value:   age,
        }
    }
    return nil
}

// Callers can extract the structured error
err := validateAge(-5)
var ve *ValidationError
if errors.As(err, &ve) {
    fmt.Printf("bad field: %s, value: %v\n", ve.Field, ve.Value)
}

// Custom error with wrapping (implements Unwrap)
type AppError struct {
    Code    int
    Message string
    Err     error
}

func (e *AppError) Error() string {
    return fmt.Sprintf("[%d] %s: %v", e.Code, e.Message, e.Err)
}

func (e *AppError) Unwrap() error {
    return e.Err
}

Sentinel Errors vs Error Types

Sentinel Errors
// Package-level error values
var (
    ErrNotFound   = errors.New("not found")
    ErrForbidden  = errors.New("forbidden")
    ErrConflict   = errors.New("conflict")
)

// Check with errors.Is
if errors.Is(err, ErrNotFound) {
    w.WriteHeader(404)
}
Error Types
// Structured error types
type NotFoundError struct {
    Resource string
    ID       string
}
func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s %s not found",
        e.Resource, e.ID)
}
// Check with errors.As
var nfe *NotFoundError
if errors.As(err, &nfe) {
    log.Printf("missing: %s", nfe.ID)
}
Approach Use When Check With
Sentinel errors The condition matters, but no extra data is needed errors.Is(err, ErrNotFound)
Error types Callers need structured data from the error errors.As(err, &target)
Opaque errors Callers only need to know if it failed err != nil

Error Wrapping Chains

Wrap errors at each layer to build a trail of context. This creates a chain that errors.Is and errors.As can walk.

// Layer 1: Repository
func (r *UserRepo) FindByID(id string) (*User, error) {
    row := r.db.QueryRow("SELECT ... WHERE id = $1", id)
    if err := row.Scan(&user); err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, ErrNotFound
        }
        return nil, fmt.Errorf("query user %s: %w", id, err)
    }
    return &user, nil
}

// Layer 2: Service
func (s *UserService) GetProfile(id string) (*Profile, error) {
    user, err := s.repo.FindByID(id)
    if err != nil {
        return nil, fmt.Errorf("get profile: %w", err)
    }
    return buildProfile(user), nil
}

// Layer 3: Handler
func (h *Handler) HandleGetProfile(w http.ResponseWriter, r *http.Request) {
    profile, err := h.svc.GetProfile(userID)
    if err != nil {
        if errors.Is(err, ErrNotFound) {
            http.Error(w, "user not found", http.StatusNotFound)
            return
        }
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(profile)
}

// Full error message: "get profile: query user abc123: connection refused"
// errors.Is(err, ErrNotFound) works through the entire chain

Best Practices

  • Always check errors. Never use _ to discard an error unless you can genuinely justify why failure is acceptable.
  • Add context when wrapping. Each layer should add what it was trying to do: fmt.Errorf("parsing config: %w", err).
  • Do not log and return. Either handle the error (log + recover) or wrap and return it. Doing both creates duplicate log entries.
  • Use sentinel errors for conditions. Export package-level var ErrXxx = errors.New("...") for errors callers need to check.
  • Return early. Handle the error case first and return, keeping the happy path un-indented.
  • Avoid panic in libraries. Reserve panic for truly unrecoverable states (programmer errors). Libraries should return errors.
  • Wrap at boundaries. When calling into another package, wrap with context. When returning from a public API, decide whether to expose the inner error chain.
Anti-Pattern: Log and Return
if err != nil {
    log.Println("failed:", err)
    return err  // caller logs again!
}
Correct: Wrap and Return
if err != nil {
    return fmt.Errorf("connecting to db: %w", err)
}
// let the top-level handler decide to log
Anti-Pattern: Deep Nesting
data, err := fetch()
if err == nil {
    parsed, err := parse(data)
    if err == nil {
        // happy path buried deep
    }
}
Correct: Early Returns
data, err := fetch()
if err != nil {
    return err
}
parsed, err := parse(data)
if err != nil {
    return err
}
// happy path at top level
06

Modules & Tooling

Go modules are the standard for dependency management. Master go.mod, workspaces, cross-compilation, and the essential toolchain.

go.mod Structure

Every Go project starts with a go.mod file at the module root. It declares the module path, Go version, and all dependencies.

// go.mod — the module manifest
module github.com/yourname/myproject

go 1.23

// Direct dependencies
require (
    github.com/gin-gonic/gin       v1.9.1
    github.com/jackc/pgx/v5        v5.5.1
    golang.org/x/sync              v0.6.0
)

// Indirect dependencies (managed automatically by go mod tidy)
require (
    github.com/bytedance/sonic     v1.10.2  // indirect
    golang.org/x/crypto            v0.18.0  // indirect
    golang.org/x/text              v0.14.0  // indirect
)

// Replace a dependency with a local copy or fork
replace github.com/broken/pkg => ../my-local-fix

// Replace with a specific fork
replace github.com/original/lib => github.com/myfork/lib v1.2.3

// Retract versions that should not be used
retract (
    v1.0.0 // Published with critical bug
    [v1.1.0, v1.2.0] // Accidental range with breaking changes
)
Directive Purpose
module Declares the module's import path (usually a repository URL)
go Minimum Go version required. Enables language features up to that version
require Lists dependencies with their minimum version (MVS — Minimum Version Selection)
replace Redirects a module to a fork, local path, or different version
exclude Prevents a specific module version from being used
retract Marks versions of your own module that should not be used

Module Commands

Command Description
go mod init <path> Initialize a new module, creating go.mod
go mod tidy Add missing and remove unused dependencies. Run after changing imports
go mod vendor Copy dependencies into a vendor/ directory for offline builds
go mod download Download modules to local cache without building
go mod graph Print the module dependency graph
go mod why <pkg> Explain why a package is needed (shortest dependency chain)
go mod edit -json Print go.mod as JSON. Also supports -require, -replace, -dropreplace
go get <pkg>@v1.2.3 Add or upgrade a dependency to a specific version
go get <pkg>@latest Upgrade to the latest version
go get -u ./... Update all dependencies to their latest minor/patch versions
# Common workflow: start a new project
mkdir myproject && cd myproject
go mod init github.com/yourname/myproject

# Add a dependency (automatically updates go.mod and go.sum)
go get github.com/gin-gonic/gin@latest

# Clean up after removing imports
go mod tidy

# Check why a dependency exists
go mod why golang.org/x/crypto

# Vendor dependencies for reproducible builds
go mod vendor
go build -mod=vendor ./...

Semantic Versioning

Go modules enforce semantic versioning (semver). The import compatibility rule is fundamental: "If an old package and a new package have the same import path, the new package must be backward-compatible with the old package."

Version Range Rules Import Path
v0.x.x Unstable / pre-release. No compatibility guarantees. Breaking changes allowed github.com/user/pkg
v1.x.x Stable. Patch and minor versions must be backward-compatible github.com/user/pkg
v2+ Major version bump. May contain breaking changes. Requires version suffix in path github.com/user/pkg/v2
// Importing different major versions of the same module
import (
    v1 "github.com/user/pkg"       // v1.x.x
    v2 "github.com/user/pkg/v2"    // v2.x.x — different import path
)

// In go.mod, major version suffix is part of the module path
require github.com/jackc/pgx/v5 v5.5.1
Major Version Paths For v2 and above, the major version must appear in both the module directive in go.mod and in all import paths. This allows v1 and v2 to coexist in the same build.

Workspaces (go.work)

Go workspaces (Go 1.18+) let you work on multiple modules simultaneously without replace directives. Ideal for multi-module repos or developing a library alongside its consumer.

// go.work — workspace manifest
go 1.23

use (
    ./api
    ./shared
    ./worker
)
# Initialize a workspace
go work init ./api ./shared ./worker

# Add another module to the workspace
go work use ./newmodule

# Sync workspace with module dependencies
go work sync

# Build all modules in the workspace
go build ./...
Don't Commit go.work The go.work file is typically for local development. Add it to .gitignore unless your entire repository is a structured multi-module workspace. CI/CD should use go.mod with proper versioned dependencies.

Build & Cross-Compilation

Go compiles to static binaries and natively supports cross-compilation. Set GOOS and GOARCH environment variables to target any supported platform.

GOOS GOARCH Target
linux amd64 Linux x86-64 (servers, containers)
linux arm64 Linux ARM64 (AWS Graviton, RPi 4)
darwin arm64 macOS Apple Silicon (M1/M2/M3/M4)
darwin amd64 macOS Intel
windows amd64 Windows x86-64
js wasm WebAssembly
wasip1 wasm WASI Preview 1
# Cross-compile for Linux from any OS
GOOS=linux GOARCH=amd64 go build -o myapp-linux ./cmd/myapp

# Build for macOS Apple Silicon
GOOS=darwin GOARCH=arm64 go build -o myapp-macos ./cmd/myapp

# Build for Windows
GOOS=windows GOARCH=amd64 go build -o myapp.exe ./cmd/myapp

# List all supported platforms
go tool dist list

# Static binary with no CGO (important for containers)
CGO_ENABLED=0 GOOS=linux go build -o myapp ./cmd/myapp

Build tags conditionally include files in a build based on OS, architecture, or custom constraints.

//go:build linux && amd64
// +build linux,amd64    (old syntax, still works)

package mypackage

// This file is only compiled on linux/amd64

//go:build !windows
// This file is compiled on every OS except Windows

//go:build integration
// Custom tag — only built with: go test -tags=integration ./...

ldflags inject values at build time, commonly used for version information.

// main.go
package main

var (
    version = "dev"
    commit  = "none"
    date    = "unknown"
)

func main() {
    fmt.Printf("myapp %s (commit %s, built %s)\n", version, commit, date)
}
# Inject version info at build time
go build -ldflags "\
  -X main.version=1.2.3 \
  -X main.commit=$(git rev-parse --short HEAD) \
  -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
  -s -w" \
  -o myapp ./cmd/myapp

# -s  strips symbol table
# -w  strips DWARF debug info
# Both reduce binary size

Essential Tools

go fmt / gofmt

Canonical formatting. Tabs for indentation, spaces for alignment. Non-negotiable in Go.

# Format all files
go fmt ./...

# Format with simplifications
gofmt -s -w .

go vet

Static analysis that catches bugs the compiler misses: printf format mismatches, unreachable code, suspicious constructs.

# Vet all packages
go vet ./...

# Specific analyzer only
go vet -printf ./...

go doc

Browse documentation from the command line. Works for any installed package.

# Package overview
go doc fmt

# Specific function
go doc fmt.Fprintf

# Show source code
go doc -src fmt.Fprintf

go generate

Run code generation commands embedded in source files as //go:generate comments.

//go:generate stringer -type=Color
//go:generate mockgen -source=repo.go

# Run all generators
go generate ./...

golangci-lint is the standard meta-linter. It runs dozens of linters in parallel and is much faster than running them individually.

# Install
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest

# Run with defaults (includes govet, errcheck, staticcheck, etc.)
golangci-lint run ./...

# Run specific linters only
golangci-lint run --enable=gosec,prealloc ./...
# .golangci.yml — project configuration
linters:
  enable:
    - govet
    - errcheck
    - staticcheck
    - gosec
    - gocritic
    - prealloc
    - exhaustive
  disable:
    - wsl
    - godox

linters-settings:
  govet:
    enable-all: true
  errcheck:
    check-type-assertions: true
  gocritic:
    enabled-tags:
      - diagnostic
      - style
      - performance

issues:
  exclude-rules:
    - path: _test\.go
      linters:
        - errcheck
        - gosec

Race detector finds data races at runtime. Essential for concurrent code.

# Run tests with race detection (slower but catches races)
go test -race ./...

# Build with race detector embedded
go build -race -o myapp ./cmd/myapp

# Race detector works at runtime — exercise concurrent paths in tests
CI Pipeline Essentials A solid Go CI pipeline runs these in order: go fmt (check formatting), go vet (catch bugs), golangci-lint run (extended linting), go test -race -cover ./... (tests with race detection and coverage).
07

Testing

Go has a powerful built-in testing framework. No external test runner needed — just go test.

Test Basics

Test files live alongside the code they test, named *_test.go. Test functions start with Test and receive a *testing.T.

// math.go
package math

func Add(a, b int) int {
    return a + b
}

func Divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}
// math_test.go
package math

import "testing"

func TestAdd(t *testing.T) {
    got := Add(2, 3)
    want := 5
    if got != want {
        t.Errorf("Add(2, 3) = %d, want %d", got, want)
    }
}

func TestDivide(t *testing.T) {
    got, err := Divide(10, 2)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if got != 5.0 {
        t.Errorf("Divide(10, 2) = %f, want 5.0", got)
    }
}

func TestDivideByZero(t *testing.T) {
    _, err := Divide(10, 0)
    if err == nil {
        t.Fatal("expected error for division by zero, got nil")
    }
}
Method Behavior
t.Error(args...) Log failure and continue running the test
t.Errorf(format, args...) Formatted failure message, continue running
t.Fatal(args...) Log failure and stop the test immediately
t.Fatalf(format, args...) Formatted failure, stop immediately
t.Log(args...) Log info (only shown with -v flag or on failure)
t.Skip(args...) Skip this test (e.g., missing dependency)
# Run all tests in current module
go test ./...

# Run tests in a specific package
go test ./internal/auth/

# Verbose output (shows t.Log, pass/fail per test)
go test -v ./...

# Run only tests matching a regex
go test -run TestDivide ./...

# Short mode (tests can check testing.Short() to skip long tests)
go test -short ./...

Table-Driven Tests

The idiomatic Go testing pattern. Define test cases as a slice of structs and iterate with subtests. This makes it easy to add cases and produces clear output.

func TestAdd(t *testing.T) {
    tests := []struct {
        name string
        a, b int
        want int
    }{
        {"positive numbers", 2, 3, 5},
        {"negative numbers", -1, -2, -3},
        {"mixed signs", -1, 5, 4},
        {"zeros", 0, 0, 0},
        {"large numbers", 1000000, 2000000, 3000000},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := Add(tt.a, tt.b)
            if got != tt.want {
                t.Errorf("Add(%d, %d) = %d, want %d",
                    tt.a, tt.b, got, tt.want)
            }
        })
    }
}
// Table-driven test with error cases
func TestDivide(t *testing.T) {
    tests := []struct {
        name    string
        a, b    float64
        want    float64
        wantErr bool
    }{
        {"normal division", 10, 2, 5, false},
        {"integer result", 9, 3, 3, false},
        {"fractional result", 1, 3, 0.3333333333333333, false},
        {"divide by zero", 10, 0, 0, true},
        {"zero numerator", 0, 5, 0, false},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := Divide(tt.a, tt.b)
            if (err != nil) != tt.wantErr {
                t.Fatalf("Divide(%v, %v) error = %v, wantErr %v",
                    tt.a, tt.b, err, tt.wantErr)
            }
            if !tt.wantErr && got != tt.want {
                t.Errorf("Divide(%v, %v) = %v, want %v",
                    tt.a, tt.b, got, tt.want)
            }
        })
    }
}

// Run a specific subtest:
// go test -run TestDivide/divide_by_zero ./...

Test Helpers

// t.Helper() marks a function as a test helper.
// When it fails, the error points to the caller, not the helper.
func assertEqual(t *testing.T, got, want int) {
    t.Helper()
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

func TestStuff(t *testing.T) {
    assertEqual(t, Add(1, 2), 3)  // failure points here, not inside assertEqual
}

// t.Cleanup() registers a function to run when the test finishes.
// Cleanups run in LIFO order, even if the test fails or panics.
func TestWithTempDir(t *testing.T) {
    dir := t.TempDir()  // automatically cleaned up
    // ... use dir ...
}

func setupDatabase(t *testing.T) *sql.DB {
    t.Helper()
    db, err := sql.Open("sqlite3", ":memory:")
    if err != nil {
        t.Fatalf("opening db: %v", err)
    }
    t.Cleanup(func() {
        db.Close()
    })
    return db
}

The testdata/ directory is a special convention. Go tooling ignores it during builds, making it perfect for test fixtures.

// testdata/ directory — ignored by go build, perfect for fixtures
// mypackage/
//   parser.go
//   parser_test.go
//   testdata/
//     valid_input.json
//     expected_output.json
//     edge_case.json

func TestParser(t *testing.T) {
    input, err := os.ReadFile("testdata/valid_input.json")
    if err != nil {
        t.Fatalf("reading test fixture: %v", err)
    }

    got, err := Parse(input)
    if err != nil {
        t.Fatalf("Parse() error: %v", err)
    }

    want, err := os.ReadFile("testdata/expected_output.json")
    if err != nil {
        t.Fatalf("reading expected output: %v", err)
    }

    if !bytes.Equal(got, want) {
        t.Errorf("output mismatch:\ngot:  %s\nwant: %s", got, want)
    }
}

Parallel Tests

Call t.Parallel() to run a test concurrently with other parallel tests. This can significantly speed up I/O-bound or slow test suites.

func TestFetchUser(t *testing.T) {
    t.Parallel()  // this test runs in parallel with other parallel tests

    user, err := FetchUser("abc123")
    if err != nil {
        t.Fatalf("FetchUser: %v", err)
    }
    if user.Name == "" {
        t.Error("expected non-empty name")
    }
}

// Table-driven + parallel: capture the loop variable correctly
func TestSlugify(t *testing.T) {
    tests := []struct {
        name  string
        input string
        want  string
    }{
        {"simple", "Hello World", "hello-world"},
        {"special chars", "Go is #1!", "go-is-1"},
        {"unicode", "Gopher Cafe", "gopher-cafe"},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()  // each subtest runs in parallel
            got := Slugify(tt.input)
            if got != tt.want {
                t.Errorf("Slugify(%q) = %q, want %q",
                    tt.input, got, tt.want)
            }
        })
    }
}
Parallel Test Pitfalls Parallel tests must not share mutable state. Each subtest should only use its own tt variable. Shared setup should use sync.Once or be done before calling t.Parallel(). Set max parallelism with go test -parallel 4.

Benchmarks

Benchmark functions measure performance. They live in _test.go files and start with Benchmark.

func BenchmarkAdd(b *testing.B) {
    for b.Loop() {  // Go 1.24+: preferred over range b.N
        Add(42, 58)
    }
}

// Pre-1.24 style (still common)
func BenchmarkAddClassic(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(42, 58)
    }
}

// Benchmark with setup that shouldn't be timed
func BenchmarkParse(b *testing.B) {
    data, err := os.ReadFile("testdata/large.json")
    if err != nil {
        b.Fatalf("setup: %v", err)
    }

    b.ResetTimer()          // exclude setup time
    b.ReportAllocs()        // report memory allocations

    for b.Loop() {
        Parse(data)
    }
}

// Sub-benchmarks for comparing approaches
func BenchmarkConcat(b *testing.B) {
    b.Run("plus", func(b *testing.B) {
        for b.Loop() {
            _ = "hello" + " " + "world"
        }
    })
    b.Run("fmt", func(b *testing.B) {
        for b.Loop() {
            _ = fmt.Sprintf("%s %s", "hello", "world")
        }
    })
    b.Run("builder", func(b *testing.B) {
        for b.Loop() {
            var buf strings.Builder
            buf.WriteString("hello")
            buf.WriteString(" ")
            buf.WriteString("world")
            _ = buf.String()
        }
    })
}
# Run benchmarks (tests run too unless you filter)
go test -bench=. ./...

# Run only benchmarks, skip tests
go test -bench=. -run=^$ ./...

# Run specific benchmark
go test -bench=BenchmarkConcat ./...

# With memory stats
go test -bench=. -benchmem ./...

# Longer benchmark runs for more stable results
go test -bench=. -benchtime=5s ./...

# Compare benchmarks with benchstat
go test -bench=. -count=10 ./... > old.txt
# ... make optimization ...
go test -bench=. -count=10 ./... > new.txt
benchstat old.txt new.txt

Examples & Coverage

Example functions serve as both documentation and verified tests. They appear in go doc output and are executed during go test.

// Example functions — verified documentation
func ExampleAdd() {
    fmt.Println(Add(2, 3))
    // Output: 5
}

func ExampleDivide() {
    result, err := Divide(10, 3)
    if err != nil {
        fmt.Println("error:", err)
        return
    }
    fmt.Printf("%.2f\n", result)
    // Output: 3.33
}

// Example for a specific method
func ExampleUser_FullName() {
    u := User{First: "Rob", Last: "Pike"}
    fmt.Println(u.FullName())
    // Output: Rob Pike
}

// Unordered output (for maps, goroutines, etc.)
func ExampleKeys() {
    m := map[string]int{"a": 1, "b": 2, "c": 3}
    for _, k := range Keys(m) {
        fmt.Println(k)
    }
    // Unordered output:
    // a
    // b
    // c
}
# Run tests with coverage percentage
go test -cover ./...

# Generate a coverage profile
go test -coverprofile=coverage.out ./...

# View coverage in browser (opens HTML report)
go tool cover -html=coverage.out

# Show coverage per function
go tool cover -func=coverage.out

# Coverage for specific packages only
go test -coverpkg=./internal/... -coverprofile=coverage.out ./...

Testify

github.com/stretchr/testify is the most popular assertion library. It provides readable assertions, requirement checks, and mocking.

import (
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestUser(t *testing.T) {
    // assert — logs failure but continues the test
    assert.Equal(t, 5, Add(2, 3))
    assert.NotNil(t, user)
    assert.Contains(t, "hello world", "world")
    assert.Len(t, items, 3)

    // require — stops the test on failure (like t.Fatal)
    user, err := GetUser("abc")
    require.NoError(t, err)         // stop here if error
    require.NotNil(t, user)         // stop here if nil
    assert.Equal(t, "Alice", user.Name)  // continue even if this fails
}
Assertion Description
assert.Equal(t, want, got) Deep equality check
assert.NotEqual(t, a, b) Not equal
assert.NoError(t, err) Error is nil
assert.Error(t, err) Error is not nil
assert.ErrorIs(t, err, target) Wraps errors.Is
assert.Nil(t, obj) Value is nil
assert.NotNil(t, obj) Value is not nil
assert.True(t, cond) Boolean is true
assert.Contains(t, s, sub) String/slice/map contains element
assert.Len(t, obj, n) Collection has length n
assert.Empty(t, obj) String/slice/map is empty
assert.JSONEq(t, want, got) JSON strings are semantically equal
// Table-driven tests with testify
func TestSlugify(t *testing.T) {
    tests := []struct {
        name  string
        input string
        want  string
    }{
        {"basic", "Hello World", "hello-world"},
        {"special", "Go & Rust!", "go-rust"},
        {"spaces", "  trim me  ", "trim-me"},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := Slugify(tt.input)
            assert.Equal(t, tt.want, got,
                "Slugify(%q) should produce correct slug", tt.input)
        })
    }
}
// Test suites with testify/suite
import "github.com/stretchr/testify/suite"

type UserServiceSuite struct {
    suite.Suite
    db  *sql.DB
    svc *UserService
}

func (s *UserServiceSuite) SetupSuite() {
    // Runs once before all tests in the suite
    s.db = setupTestDB(s.T())
    s.svc = NewUserService(s.db)
}

func (s *UserServiceSuite) TearDownSuite() {
    s.db.Close()
}

func (s *UserServiceSuite) SetupTest() {
    // Runs before each test — clean slate
    s.db.Exec("DELETE FROM users")
}

func (s *UserServiceSuite) TestCreateUser() {
    user, err := s.svc.Create("Alice", "alice@test.com")
    s.Require().NoError(err)
    s.Assert().Equal("Alice", user.Name)
    s.Assert().NotZero(user.ID)
}

func (s *UserServiceSuite) TestGetUser() {
    created, _ := s.svc.Create("Bob", "bob@test.com")
    found, err := s.svc.GetByID(created.ID)
    s.Require().NoError(err)
    s.Assert().Equal("Bob", found.Name)
}

// Run the suite
func TestUserServiceSuite(t *testing.T) {
    suite.Run(t, new(UserServiceSuite))
}
assert vs require Use require for preconditions that must pass for the rest of the test to be meaningful (e.g., no error from setup). Use assert for the actual assertions you want to check — this lets you see all failures at once instead of stopping at the first one.
08

Standard Library

Go's standard library is famously comprehensive. These are the packages you'll use in nearly every project.

fmt

Formatted I/O. The Printf family is the workhorse for output, logging, and string building.

Verb Description Example Output
%v Default format {Alice 30}
%+v Default with field names {Name:Alice Age:30}
%#v Go syntax representation main.User{Name:"Alice", Age:30}
%T Type of the value main.User
%d Integer (base 10) 42
%b Integer (base 2) 101010
%x Integer (base 16, lowercase) 2a
%s String hello
%q Quoted string "hello"
%f Float (default precision) 3.141593
%.2f Float (2 decimal places) 3.14
%e Scientific notation 3.141593e+00
%p Pointer address 0xc0000b4000
%w Wrap error (Errorf only) wraps for errors.Is/errors.As
// Print family
fmt.Println("hello", "world")           // hello world\n
fmt.Printf("name: %s, age: %d\n", name, age)
fmt.Print("no newline")

// Sprint family — return string instead of printing
s := fmt.Sprintf("user %s (#%d)", name, id)
msg := fmt.Sprintf("%.2f%%", 99.5)      // "99.50%"

// Fprint family — write to any io.Writer
fmt.Fprintf(os.Stderr, "error: %v\n", err)
fmt.Fprintf(w, "HTTP response: %s", body)

// Errorf — create formatted errors (with optional wrapping)
err := fmt.Errorf("read config %s: %w", path, err)

strings & strconv

import "strings"

strings.Contains("seafood", "foo")      // true
strings.HasPrefix("gopher", "go")       // true
strings.HasSuffix("main.go", ".go")     // true

strings.ToUpper("hello")                // "HELLO"
strings.ToLower("HELLO")                // "hello"
strings.TrimSpace("  hi  ")             // "hi"
strings.Trim("***hi***", "*")           // "hi"

strings.Split("a,b,c", ",")             // ["a", "b", "c"]
strings.SplitN("a,b,c", ",", 2)         // ["a", "b,c"]
strings.Join([]string{"a","b","c"}, "-") // "a-b-c"

strings.Replace("oink oink", "oink", "moo", 1)  // "moo oink"
strings.ReplaceAll("oink oink", "oink", "moo")  // "moo moo"

strings.Count("cheese", "e")             // 3
strings.Index("chicken", "ken")          // 4
strings.Repeat("na", 4)                  // "nananana"

// strings.Builder — efficient string concatenation
var b strings.Builder
for i := 0; i < 100; i++ {
    fmt.Fprintf(&b, "item %d\n", i)
}
result := b.String()
import "strconv"

// String ↔ Integer
strconv.Itoa(42)                         // "42"
n, err := strconv.Atoi("42")            // 42, nil
n, err := strconv.Atoi("nope")          // 0, error

// String ↔ Float
f, err := strconv.ParseFloat("3.14", 64)  // 3.14, nil
s := strconv.FormatFloat(3.14, 'f', 2, 64) // "3.14"

// String ↔ Bool
b, err := strconv.ParseBool("true")     // true, nil
s := strconv.FormatBool(true)            // "true"

// String ↔ Integer (with base and bit size)
n, err := strconv.ParseInt("ff", 16, 64) // 255, nil
s := strconv.FormatInt(255, 16)           // "ff"

io & bufio

The io.Reader and io.Writer interfaces are the backbone of Go I/O. Nearly everything composes through them.

// The two fundamental interfaces
type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

// Copy from any Reader to any Writer
n, err := io.Copy(dst, src)          // e.g., file to HTTP response

// Read everything into memory
data, err := io.ReadAll(resp.Body)

// Compose readers
r := io.LimitReader(src, 1024*1024)  // read at most 1MB
r := io.TeeReader(src, os.Stdout)    // read and tee to stdout

// Pipe — synchronous in-memory pipe
pr, pw := io.Pipe()
go func() {
    defer pw.Close()
    json.NewEncoder(pw).Encode(data)
}()
// pr now streams the JSON as it's written
import "bufio"

// Scanner — read line by line (default) or by custom split
file, _ := os.Open("data.txt")
defer file.Close()

scanner := bufio.NewScanner(file)
for scanner.Scan() {
    line := scanner.Text()
    fmt.Println(line)
}
if err := scanner.Err(); err != nil {
    log.Fatal(err)
}

// Scanner with custom split function
scanner.Split(bufio.ScanWords)  // split by words instead of lines

// Buffered writer — reduces system calls
w := bufio.NewWriter(file)
fmt.Fprintf(w, "buffered write: %d\n", 42)
w.Flush()  // don't forget to flush!

encoding/json

type User struct {
    ID        int       `json:"id"`
    Name      string    `json:"name"`
    Email     string    `json:"email,omitempty"`  // omit if empty
    Password  string    `json:"-"`                // never marshal
    CreatedAt time.Time `json:"created_at"`
}

// Marshal — struct → JSON bytes
user := User{ID: 1, Name: "Alice", Email: "alice@test.com"}
data, err := json.Marshal(user)
// {"id":1,"name":"Alice","email":"alice@test.com","created_at":"..."}

// Pretty print
data, err := json.MarshalIndent(user, "", "  ")

// Unmarshal — JSON bytes → struct
var u User
err := json.Unmarshal(data, &u)

// Unmarshal into a map (dynamic JSON)
var m map[string]any
err := json.Unmarshal(data, &m)
name := m["name"].(string)
// Streaming with json.Decoder (efficient for HTTP bodies, files)
func decodeUsers(r io.Reader) ([]User, error) {
    var users []User
    dec := json.NewDecoder(r)
    if err := dec.Decode(&users); err != nil {
        return nil, err
    }
    return users, nil
}

// In an HTTP handler
func handleCreateUser(w http.ResponseWriter, r *http.Request) {
    var user User
    if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
        http.Error(w, "invalid JSON", http.StatusBadRequest)
        return
    }
    // ... process user ...
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

// Custom marshaling
type Status int

const (
    Active Status = iota
    Inactive
)

func (s Status) MarshalJSON() ([]byte, error) {
    names := map[Status]string{Active: "active", Inactive: "inactive"}
    return json.Marshal(names[s])
}

func (s *Status) UnmarshalJSON(data []byte) error {
    var name string
    if err := json.Unmarshal(data, &name); err != nil {
        return err
    }
    switch name {
    case "active":
        *s = Active
    case "inactive":
        *s = Inactive
    default:
        return fmt.Errorf("unknown status: %s", name)
    }
    return nil
}

net/http

Go's HTTP package is production-ready out of the box. Go 1.22+ added method-based routing to the default ServeMux.

// Basic HTTP server (Go 1.22+ enhanced routing)
mux := http.NewServeMux()

// Method + path patterns (Go 1.22+)
mux.HandleFunc("GET /users", listUsers)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("GET /users/{id}", getUser)     // path parameter
mux.HandleFunc("DELETE /users/{id}", deleteUser)

// Path parameter extraction
func getUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")  // Go 1.22+
    // ... look up user by id ...
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

// Production server with timeouts
server := &http.Server{
    Addr:         ":8080",
    Handler:      mux,
    ReadTimeout:  5 * time.Second,
    WriteTimeout: 10 * time.Second,
    IdleTimeout:  120 * time.Second,
}

log.Println("listening on :8080")
log.Fatal(server.ListenAndServe())
// Middleware pattern
func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
    })
}

// Apply middleware
handler := loggingMiddleware(mux)
// HTTP client with timeout (never use http.DefaultClient in production)
client := &http.Client{
    Timeout: 10 * time.Second,
}

// Simple GET
resp, err := client.Get("https://api.example.com/data")
if err != nil {
    return fmt.Errorf("GET request: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
    return fmt.Errorf("unexpected status: %d", resp.StatusCode)
}

var result Data
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
    return fmt.Errorf("decoding response: %w", err)
}

// POST with JSON body
payload, _ := json.Marshal(user)
resp, err := client.Post(
    "https://api.example.com/users",
    "application/json",
    bytes.NewReader(payload),
)

// Custom request with headers
req, err := http.NewRequestWithContext(ctx, "PUT", url, body)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
Always Set Timeouts The default http.Client and http.Server have no timeouts. In production, always configure ReadTimeout, WriteTimeout, and client Timeout to prevent resource leaks from slow or stalled connections.

slog (Go 1.21+)

Structured logging in the standard library. Replaces log for any production application.

import "log/slog"

// Default text logger
slog.Info("server started", "port", 8080)
// 2024-01-15 10:30:00 INFO server started port=8080

slog.Warn("slow query", "duration", time.Since(start), "query", q)
slog.Error("request failed", "err", err, "method", r.Method)

// JSON handler (for production / log aggregators)
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,
}))
slog.SetDefault(logger)

slog.Info("user created", "id", user.ID, "email", user.Email)
// {"time":"2024-01-15T10:30:00Z","level":"INFO","msg":"user created","id":42,"email":"alice@test.com"}

// Log levels
slog.Debug("verbose detail")   // hidden at default level
slog.Info("normal operation")
slog.Warn("something unusual")
slog.Error("something failed")
// Structured attributes and groups
slog.Info("request",
    slog.String("method", r.Method),
    slog.String("path", r.URL.Path),
    slog.Int("status", status),
    slog.Duration("latency", duration),
    slog.Group("user",
        slog.Int("id", user.ID),
        slog.String("role", user.Role),
    ),
)
// ... "user":{"id":42,"role":"admin"} ...

// Logger with persistent context (child loggers)
reqLogger := slog.With("request_id", requestID, "user_id", userID)
reqLogger.Info("processing order", "order_id", orderID)
reqLogger.Error("payment failed", "err", err)

// Dynamic log level
var level slog.LevelVar
level.Set(slog.LevelDebug)  // change at runtime

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

time

Go uses a unique reference time for formatting: Mon Jan 2 15:04:05 MST 2006 (1/2 3:4:5 6 7). Memorize it by the mnemonic: 01/02 03:04:05 PM '06 -0700.

import "time"

// Current time
now := time.Now()
fmt.Println(now)  // 2024-01-15 10:30:00.123456 -0800 PST

// Duration
elapsed := time.Since(start)    // time.Duration since start
fmt.Printf("took %v\n", elapsed)
fmt.Printf("took %dms\n", elapsed.Milliseconds())

d := 5*time.Second + 500*time.Millisecond
time.Sleep(d)
// Formatting — use the reference time as a template
now := time.Now()

now.Format("2006-01-02")                   // "2024-01-15"
now.Format("2006-01-02 15:04:05")          // "2024-01-15 10:30:00"
now.Format(time.RFC3339)                   // "2024-01-15T10:30:00-08:00"
now.Format("Mon, 02 Jan 2006")             // "Mon, 15 Jan 2024"
now.Format("3:04 PM")                      // "10:30 AM"

// Parsing — same reference time approach
t, err := time.Parse("2006-01-02", "2024-01-15")
t, err := time.Parse(time.RFC3339, "2024-01-15T10:30:00Z")
t, err := time.ParseInLocation("2006-01-02", "2024-01-15", time.UTC)
// Timers and tickers
// Timer — fires once after duration
timer := time.NewTimer(5 * time.Second)
<-timer.C  // blocks until timer fires

// AfterFunc — run function after delay
time.AfterFunc(2*time.Second, func() {
    fmt.Println("delayed execution")
})

// Ticker — fires repeatedly at interval
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()

for range ticker.C {
    fmt.Println("tick", time.Now())
    // break when done
}

// Common operations
t1.Before(t2)                     // bool
t1.After(t2)                      // bool
t1.Equal(t2)                      // bool (handles monotonic clock)
t1.Add(24 * time.Hour)            // time.Time (tomorrow)
t1.Sub(t2)                        // time.Duration (difference)
t1.Truncate(time.Hour)            // round down to hour
t1.UTC()                          // convert to UTC
t1.In(time.FixedZone("EST", -5*3600)) // convert timezone
Reference Time Mnemonic Go's reference time is 01/02 03:04:05 PM '06 -0700. Each component is a different digit: month=01, day=02, hour=03, minute=04, second=05, year=06, timezone=-0700. If you need 24-hour format, use 15 for the hour.
09

Ecosystem

Go's ecosystem favors focused, well-composed libraries over monolithic frameworks. These are the essential third-party packages.

Cobra — CLI Framework

github.com/spf13/cobra is the standard for building CLI applications. Used by kubectl, Hugo, GitHub CLI, and Docker.

// cmd/root.go — root command
package cmd

import (
    "fmt"
    "os"
    "github.com/spf13/cobra"
)

var verbose bool

var rootCmd = &cobra.Command{
    Use:   "myapp",
    Short: "A brief description of your app",
    Long:  `A longer description with examples and usage.`,
}

func Execute() {
    if err := rootCmd.Execute(); err != nil {
        os.Exit(1)
    }
}

func init() {
    // Persistent flags — available to this command and all subcommands
    rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false,
        "enable verbose output")

    // Add subcommands
    rootCmd.AddCommand(serveCmd)
    rootCmd.AddCommand(migrateCmd)
}
// cmd/serve.go — subcommand
var port int

var serveCmd = &cobra.Command{
    Use:   "serve",
    Short: "Start the HTTP server",
    Args:  cobra.NoArgs,
    RunE: func(cmd *cobra.Command, args []string) error {
        fmt.Printf("Starting server on :%d\n", port)
        return startServer(port)
    },
}

func init() {
    // Local flag — only for this command
    serveCmd.Flags().IntVarP(&port, "port", "p", 8080, "server port")
}

// cmd/migrate.go — subcommand with positional args
var migrateCmd = &cobra.Command{
    Use:   "migrate [up|down]",
    Short: "Run database migrations",
    Args:  cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        direction := args[0]
        if direction != "up" && direction != "down" {
            return fmt.Errorf("invalid direction: %s (use 'up' or 'down')", direction)
        }
        return runMigrations(direction)
    },
}
// main.go
package main

import "myapp/cmd"

func main() {
    cmd.Execute()
}

// Usage:
// myapp serve --port 3000
// myapp migrate up --verbose
// myapp --help

Bubbletea — TUI Framework

github.com/charmbracelet/bubbletea builds terminal UIs using The Elm Architecture: Model, Update, View.

import (
    tea "github.com/charmbracelet/bubbletea"
    "fmt"
)

// Model holds all application state
type model struct {
    choices  []string
    cursor   int
    selected map[int]struct{}
}

func initialModel() model {
    return model{
        choices:  []string{"Buy groceries", "Wash dishes", "Do laundry"},
        selected: make(map[int]struct{}),
    }
}

// Init returns an initial command (or nil)
func (m model) Init() tea.Cmd {
    return nil
}

// Update handles messages and returns updated model + optional command
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "ctrl+c", "q":
            return m, tea.Quit
        case "up", "k":
            if m.cursor > 0 {
                m.cursor--
            }
        case "down", "j":
            if m.cursor < len(m.choices)-1 {
                m.cursor++
            }
        case "enter", " ":
            if _, ok := m.selected[m.cursor]; ok {
                delete(m.selected, m.cursor)
            } else {
                m.selected[m.cursor] = struct{}{}
            }
        }
    }
    return m, nil
}

// View renders the UI as a string
func (m model) View() string {
    s := "What should we buy?\n\n"
    for i, choice := range m.choices {
        cursor := " "
        if m.cursor == i {
            cursor = ">"
        }
        checked := " "
        if _, ok := m.selected[i]; ok {
            checked = "x"
        }
        s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
    }
    s += "\nPress q to quit.\n"
    return s
}

func main() {
    p := tea.NewProgram(initialModel())
    if _, err := p.Run(); err != nil {
        log.Fatal(err)
    }
}
// Bubbletea commands — async operations
type statusMsg int

func checkServer() tea.Msg {
    resp, err := http.Get("https://example.com")
    if err != nil {
        return errMsg{err}
    }
    return statusMsg(resp.StatusCode)
}

// In Update:
case tea.KeyMsg:
    if msg.String() == "c" {
        return m, checkServer  // returns a tea.Cmd (function)
    }
case statusMsg:
    m.status = int(msg)
case errMsg:
    m.err = msg.err

// Bubbles — prebuilt components
// github.com/charmbracelet/bubbles
// spinner, textinput, list, table, viewport, progress, paginator
Bubbletea v2 Bubbletea v2 introduces context-based commands, improved event handling, and a refined API. The core Model/Update/View pattern remains the same, but tea.Cmd now accepts a context.Context.

Web Frameworks

Go 1.22+ made the standard library router much more capable. Third-party routers add middleware chains, route groups, and convenience.

// chi — lightweight, idiomatic, composable router
import "github.com/go-chi/chi/v5"
import "github.com/go-chi/chi/v5/middleware"

r := chi.NewRouter()

// Middleware stack
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Timeout(30 * time.Second))

// Routes
r.Get("/", homeHandler)
r.Post("/users", createUser)

// Route groups
r.Route("/api/v1", func(r chi.Router) {
    r.Use(authMiddleware)

    r.Get("/users", listUsers)
    r.Post("/users", createUser)

    r.Route("/users/{userID}", func(r chi.Router) {
        r.Get("/", getUser)
        r.Put("/", updateUser)
        r.Delete("/", deleteUser)
    })
})

// Path parameter
func getUser(w http.ResponseWriter, r *http.Request) {
    userID := chi.URLParam(r, "userID")
    // ...
}

http.ListenAndServe(":3000", r)
Feature net/http (1.22+) chi Gin Echo
Method routing Yes (native) Yes Yes Yes
Path parameters {id} {id} :id :id
Middleware Manual wrapping Stack-based Stack-based Stack-based
Route groups No Yes Yes Yes
Handler signature http.Handler http.Handler gin.HandlerFunc echo.HandlerFunc
stdlib compatible Yes Yes (100%) Partial Partial
Dependencies Zero Zero Many Some
Best for Simple APIs Most projects Speed-focused APIs Feature-rich APIs
Recommendation Start with net/http for simple projects. Reach for chi when you need route groups and middleware chains — it's 100% compatible with net/http handlers, so you never lock into a proprietary API.

Database — GORM vs sqlx

// ── GORM — full ORM ──
import "gorm.io/gorm"
import "gorm.io/driver/postgres"

// Model definition
type User struct {
    gorm.Model                        // ID, CreatedAt, UpdatedAt, DeletedAt
    Name    string `gorm:"size:100;not null"`
    Email   string `gorm:"uniqueIndex;not null"`
    Posts   []Post `gorm:"foreignKey:AuthorID"`
}

type Post struct {
    gorm.Model
    Title    string
    Body     string
    AuthorID uint
}

// Connect
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})

// Auto-migrate (create/update tables)
db.AutoMigrate(&User{}, &Post{})

// CRUD
db.Create(&User{Name: "Alice", Email: "alice@test.com"})

var user User
db.First(&user, 1)                          // by primary key
db.Where("email = ?", email).First(&user)   // by condition

db.Model(&user).Update("Name", "Bob")
db.Model(&user).Updates(User{Name: "Bob", Email: "bob@test.com"})

db.Delete(&user, 1)

// Queries
var users []User
db.Where("name LIKE ?", "%alice%").
    Order("created_at DESC").
    Limit(10).
    Find(&users)

// Preload associations
db.Preload("Posts").Find(&users)
// ── sqlx — enhanced database/sql ──
import "github.com/jmoiron/sqlx"
import _ "github.com/lib/pq"

db, err := sqlx.Connect("postgres", dsn)

// Struct scanning (no manual Scan calls)
type User struct {
    ID    int    `db:"id"`
    Name  string `db:"name"`
    Email string `db:"email"`
}

// Get single row into struct
var user User
err := db.Get(&user, "SELECT * FROM users WHERE id = $1", 42)

// Select multiple rows into slice
var users []User
err := db.Select(&users, "SELECT * FROM users WHERE active = $1", true)

// Named queries — use struct field names
_, err := db.NamedExec(
    `INSERT INTO users (name, email) VALUES (:name, :email)`,
    user,
)

// Named query with map
_, err := db.NamedExec(
    `UPDATE users SET name = :name WHERE id = :id`,
    map[string]any{"name": "Alice", "id": 42},
)

// Transactions
tx, err := db.Beginx()
tx.NamedExec(`INSERT INTO users ...`, user)
tx.NamedExec(`INSERT INTO audit_log ...`, entry)
tx.Commit()  // or tx.Rollback()
Aspect GORM sqlx
Approach Full ORM (Active Record) Enhanced database/sql
SQL knowledge Optional (query builder) Required (you write SQL)
Migrations Built-in AutoMigrate Use external tool (goose, migrate)
Associations Built-in (HasMany, BelongsTo) Manual JOINs
Performance Good (some overhead) Near raw SQL
Best for Rapid CRUD, complex relations Full SQL control, performance-critical

Viper — Configuration

github.com/spf13/viper handles configuration from files, environment variables, flags, and remote systems. Often paired with Cobra.

import "github.com/spf13/viper"

// Set defaults
viper.SetDefault("server.port", 8080)
viper.SetDefault("server.timeout", "30s")
viper.SetDefault("database.max_conns", 10)

// Read config file
viper.SetConfigName("config")     // config.yaml, config.json, etc.
viper.SetConfigType("yaml")
viper.AddConfigPath(".")           // search current directory
viper.AddConfigPath("$HOME/.myapp")

if err := viper.ReadInConfig(); err != nil {
    if _, ok := err.(viper.ConfigFileNotFoundError); ok {
        // Config file not found — use defaults
    } else {
        log.Fatalf("reading config: %v", err)
    }
}

// Environment variables
viper.SetEnvPrefix("MYAPP")           // MYAPP_SERVER_PORT
viper.AutomaticEnv()
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))

// Access values
port := viper.GetInt("server.port")
timeout := viper.GetDuration("server.timeout")
debug := viper.GetBool("debug")
// Unmarshal into a struct (the recommended approach)
type Config struct {
    Server struct {
        Port    int           `mapstructure:"port"`
        Timeout time.Duration `mapstructure:"timeout"`
    } `mapstructure:"server"`
    Database struct {
        DSN      string `mapstructure:"dsn"`
        MaxConns int    `mapstructure:"max_conns"`
    } `mapstructure:"database"`
    Debug bool `mapstructure:"debug"`
}

var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
    log.Fatalf("unmarshaling config: %v", err)
}

// config.yaml
// server:
//   port: 3000
//   timeout: 30s
// database:
//   dsn: postgres://localhost/myapp
//   max_conns: 25
// debug: false
Priority Order Viper resolves values in this order (highest to lowest): explicit Set() calls, flags, environment variables, config file, key/value store, defaults. This means environment variables always override config file values.

Wire — Dependency Injection

github.com/google/wire provides compile-time dependency injection. It generates code — no runtime reflection or service locators.

// providers.go — provider functions return dependencies
func NewDatabase(cfg *Config) (*sql.DB, error) {
    return sql.Open("postgres", cfg.DatabaseDSN)
}

func NewUserRepo(db *sql.DB) *UserRepo {
    return &UserRepo{db: db}
}

func NewUserService(repo *UserRepo, logger *slog.Logger) *UserService {
    return &UserService{repo: repo, logger: logger}
}

func NewServer(svc *UserService, cfg *Config) *http.Server {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /users", svc.ListUsers)
    return &http.Server{Addr: cfg.Addr, Handler: mux}
}
// wire.go — injector declaration (input for wire tool)
//go:build wireinject

package main

import "github.com/google/wire"

func InitializeServer(cfg *Config) (*http.Server, error) {
    wire.Build(
        NewDatabase,
        NewUserRepo,
        NewUserService,
        NewLogger,
        NewServer,
    )
    return nil, nil  // wire replaces this
}

// Provider sets — group related providers
var DatabaseSet = wire.NewSet(NewDatabase, NewUserRepo)
var ServiceSet = wire.NewSet(DatabaseSet, NewUserService, NewLogger)

func InitializeServer(cfg *Config) (*http.Server, error) {
    wire.Build(ServiceSet, NewServer)
    return nil, nil
}
# Generate wire_gen.go (the actual DI code)
go install github.com/google/wire/cmd/wire@latest
wire ./...

# The generated wire_gen.go contains a real function:
func InitializeServer(cfg *Config) (*http.Server, error) {
    db, err := NewDatabase(cfg)
    if err != nil { return nil, err }
    repo := NewUserRepo(db)
    logger := NewLogger()
    svc := NewUserService(repo, logger)
    server := NewServer(svc, cfg)
    return server, nil
}
When to Use Wire Wire shines in larger applications with many interdependent services. For small apps, simple constructor functions called in main() work fine. Wire's advantage is that it catches missing or circular dependencies at compile time, not runtime.
10

Go Idioms

Go has strong opinions. These proverbs, patterns, and conventions define idiomatic Go code.

Go Proverbs

From Rob Pike's Go Proverbs talk. These aren't just slogans — they shape how Go code is written and reviewed.

"Don't communicate by sharing memory; share memory by communicating"

Instead of using mutexes to protect shared state, send data through channels. Let goroutines own their data and communicate via message passing.

Shared Memory
var mu sync.Mutex
var balance int

func Deposit(amount int) {
    mu.Lock()
    balance += amount
    mu.Unlock()
}
Message Passing
type Bank struct {
    deposits chan int
    balances chan int
}

func (b *Bank) Run() {
    balance := 0
    for {
        select {
        case amount := <-b.deposits:
            balance += amount
        case b.balances <- balance:
        }
    }
}
"Accept interfaces, return structs"

Functions should accept interfaces for flexibility and return concrete types for clarity. The caller decides when abstraction is needed, not the implementer.

// Good: accept io.Reader (any data source works)
func Process(r io.Reader) (*Result, error) {
    data, err := io.ReadAll(r)
    // ...
    return &Result{Data: data}, nil
}

// Works with files, HTTP bodies, buffers, strings...
Process(os.Stdin)
Process(resp.Body)
Process(strings.NewReader("hello"))
Process(&bytes.Buffer{})
"Errors are values"

Errors aren't special control flow. They're regular values you can store, compare, wrap, and pass around. This enables patterns like error accumulation and programmatic error handling.

// Errors as values enable creative patterns
type errWriter struct {
    w   io.Writer
    err error
}

func (ew *errWriter) write(buf []byte) {
    if ew.err != nil {
        return  // skip if already failed
    }
    _, ew.err = ew.w.Write(buf)
}

// Clean sequential writes — check error once at the end
ew := &errWriter{w: fd}
ew.write(header)
ew.write(body)
ew.write(footer)
if ew.err != nil {
    return ew.err
}
"Make the zero value useful"

Design types so their zero value (all fields at default) is immediately usable without a constructor.

// sync.Mutex — zero value is an unlocked mutex
var mu sync.Mutex  // ready to use, no initialization needed

// bytes.Buffer — zero value is an empty buffer
var buf bytes.Buffer
buf.WriteString("hello")

// Your own types should follow this pattern
type Server struct {
    Addr    string        // "" defaults handled in ListenAndServe
    Handler http.Handler  // nil means DefaultServeMux
    Timeout time.Duration // 0 means no timeout
}

// Works with zero value
s := Server{}
s.ListenAndServe()  // sensible defaults for all fields
"A little copying is better than a little dependency"

Don't import a large package for one small utility function. Copy a few lines instead of adding a dependency with its own transitive dependencies.

// Don't import a utility package just for this:
func min(a, b int) int {
    if a < b {
        return a
    }
    return b
}

// Note: Go 1.21+ added built-in min/max, but the principle
// applies broadly to small helper functions
"Clear is better than clever"

Write code that's obvious at first read. Avoid clever one-liners, tricky bit manipulation, and obscure optimizations unless they're genuinely needed and well-commented.

Clever
// What does this do?
v = v[:0+copy(v[0:], v[i:])]
Clear
// Remove element at index i
v = append(v[:i], v[i+1:]...)
"The bigger the interface, the weaker the abstraction"

Small interfaces are powerful. io.Reader (one method) is implemented by hundreds of types. An interface with 20 methods is rarely reusable.

// Strong abstraction — small interface
type Stringer interface {
    String() string
}

// Weak abstraction — too many methods
type Repository interface {
    Create(ctx context.Context, u *User) error
    GetByID(ctx context.Context, id string) (*User, error)
    GetByEmail(ctx context.Context, email string) (*User, error)
    Update(ctx context.Context, u *User) error
    Delete(ctx context.Context, id string) error
    List(ctx context.Context, opts ListOpts) ([]*User, error)
    Count(ctx context.Context) (int, error)
    // ... this interface is too specific to be reusable
}

// Better: compose small interfaces or use concrete types
type UserReader interface {
    GetByID(ctx context.Context, id string) (*User, error)
}

type UserWriter interface {
    Create(ctx context.Context, u *User) error
    Update(ctx context.Context, u *User) error
}
"Don't panic"

Reserve panic for truly unrecoverable states — programmer errors, impossible conditions, failed invariants during initialization. Libraries should never panic; always return errors.

// Acceptable: program cannot continue without this
func mustCompile(pattern string) *regexp.Regexp {
    re, err := regexp.Compile(pattern)
    if err != nil {
        panic(fmt.Sprintf("invalid regex %q: %v", pattern, err))
    }
    return re
}

// Only use must* patterns for package-level initialization
var emailRe = mustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+$`)

// Never panic in normal code paths — return an error instead

Project Layout

There's no official enforced layout, but the community has converged on a standard structure for non-trivial projects.

myproject/
├── cmd/                    # Application entry points
│   ├── server/
│   │   └── main.go         # go run ./cmd/server
│   └── worker/
│       └── main.go         # go run ./cmd/worker
│
├── internal/               # Private packages (enforced by Go toolchain)
│   ├── auth/               # Authentication logic
│   │   ├── auth.go
│   │   └── auth_test.go
│   ├── database/           # Database layer
│   │   ├── postgres.go
│   │   └── migrations/
│   └── handler/            # HTTP handlers
│       ├── user.go
│       └── user_test.go
│
├── pkg/                    # Public packages (importable by other projects)
│   └── validator/          # Reusable validation utilities
│       ├── validator.go
│       └── validator_test.go
│
├── api/                    # API definitions (OpenAPI, protobuf)
│   └── openapi.yaml
│
├── go.mod
├── go.sum
├── Makefile
└── README.md
The internal/ Rule Code inside internal/ can only be imported by code in the parent of internal/. This is enforced by the Go compiler. Use it to hide implementation details from external consumers. Packages in internal/ are your private API — refactor freely without breaking others.
Directory Purpose Importable Externally?
cmd/ Each subdirectory is a main package (one binary per dir) No (main packages)
internal/ Private application code — enforced access restriction No (compiler-enforced)
pkg/ Public library code safe for external use Yes

Package naming conventions:

  • Short, lowercase, no underscores: http, json, auth, user
  • Singular nouns: user not users, model not models
  • No stutter: http.Server not http.HTTPServer
  • Avoid util, common, helpers: these are meaningless — put code where it belongs
  • Package name is part of the API: callers write auth.NewClient(), so name accordingly

Best Practices

init() functions run automatically when a package is imported. Use them sparingly.

// init() runs automatically — no explicit call needed
func init() {
    // Acceptable uses:
    // - Register database drivers
    // - Set up package-level regex or lookup tables
    // - Initialize default loggers
}

// Avoid init() for:
// - Complex logic that can fail (no error return)
// - Side effects that make testing hard
// - Anything that depends on runtime configuration

// Better alternative: explicit initialization
func Setup(cfg *Config) error {
    // ... can return errors, accepts configuration
    return nil
}

go:embed (Go 1.16+) embeds static files directly into the binary.

import "embed"

//go:embed templates/*.html
var templates embed.FS

//go:embed static/*
var staticFiles embed.FS

//go:embed version.txt
var version string  // single file as string

//go:embed schema.sql
var schema []byte   // single file as bytes

// Use with http.FileServer
http.Handle("/static/",
    http.FileServer(http.FS(staticFiles)))

// Use with html/template
tmpl, err := template.ParseFS(templates, "templates/*.html")

Effective error messages follow a consistent style.

Unhelpful Errors
return errors.New("failed")
return errors.New("Error occurred")
return fmt.Errorf("ERROR: could not read the file!")
return fmt.Errorf("ReadConfig: %w", err)
Effective Errors
return errors.New("connection refused")
return fmt.Errorf("read config %s: %w", path, err)
return fmt.Errorf("parse user age: %w", err)
return fmt.Errorf("dial %s:%d: %w", host, port, err)
  • Lowercase, no trailing punctuation
  • Include relevant context (filename, ID, operation)
  • Build a chain: "fetch user 42: query db: connection refused"
  • Don't start with "failed to" or "error" — it's already an error

Comment conventions (godoc format):

// Package auth provides authentication and authorization
// middleware for HTTP servers.
package auth

// Authenticator validates credentials and returns a user identity.
// It returns ErrUnauthorized if the credentials are invalid.
type Authenticator interface {
    Authenticate(ctx context.Context, token string) (*Identity, error)
}

// NewJWTAuth creates an Authenticator that validates JWT tokens
// using the provided signing key. The key must be at least 256 bits.
//
// Example:
//
//  auth := NewJWTAuth([]byte(os.Getenv("JWT_SECRET")))
//  identity, err := auth.Authenticate(ctx, token)
func NewJWTAuth(key []byte) *JWTAuth {
    // ...
}

// ErrUnauthorized is returned when credentials are invalid or expired.
var ErrUnauthorized = errors.New("unauthorized")

// Deprecated: Use NewJWTAuth instead.
func NewAuth(key string) *Auth { ... }
The Go Readability Test Good Go code reads like a well-written technical document. If you have to re-read a function to understand what it does, simplify it. If a comment explains what the code does (rather than why), the code itself isn't clear enough. Aim for code that a new team member can understand in one pass.