Go

Go is designed for simplicity and explicit code. It has a small specification, fast compilation, garbage collection, and built-in concurrency primitives. There are very few ways to do things β€” once you learn the idiomatic approach, all Go code looks similar.


🟒 Junior

Types and Zero Values

Every variable in Go has a zero value β€” there is no undefined or null (except for pointers, slices, maps, channels, interfaces, and functions, which zero to nil).

var name string    // ""
var count int      // 0
var active bool    // false
var ptr *int       // nil

// Short declaration (inside functions only)
name := "Alice"
count, active := 42, true

Go is strongly typed with no implicit coercion. Convert explicitly: float64(count), int(someFloat).

Structs

Structs are Go’s primary composite type. There are no classes β€” methods are attached to types.

type User struct {
    ID    int
    Name  string
    Email string
    admin bool  // unexported (lowercase = package-private)
}

// Method with pointer receiver β€” can modify the struct
func (u *User) Promote() {
    u.admin = true
}

// Method with value receiver β€” gets a copy, cannot modify
func (u User) DisplayName() string {
    return fmt.Sprintf("%s <%s>", u.Name, u.Email)
}

u := User{ID: 1, Name: "Alice", Email: "alice@example.com"}
u.Promote()
fmt.Println(u.DisplayName())

Use pointer receivers consistently within a type β€” mixing pointer and value receivers is confusing.

Slices and Maps

Slices are views into an underlying array. They have length (current elements) and capacity (underlying array size).

nums := []int{1, 2, 3}
nums = append(nums, 4, 5)     // may allocate new backing array
sub  := nums[1:3]              // [2, 3] β€” shares backing array
copy(dst, src)                 // safe independent copy

// Pre-allocate when you know the size
data := make([]int, 0, 1000)
for i := range 1000 {
    data = append(data, i)
}

Maps are hash tables. The zero value is nil β€” you must make or initialize before writing.

counts := make(map[string]int)
counts["alice"]++              // zero value for missing key is 0

// Check existence
val, ok := counts["bob"]
if !ok { fmt.Println("bob not found") }

// Iterate (order is random)
for key, val := range counts {
    fmt.Printf("%s: %d\n", key, val)
}

Error Handling

Go functions return errors as explicit values. Ignoring an error is a deliberate choice you must make with _.

func readFile(path string) ([]byte, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("readFile %q: %w", path, err)
    }
    return data, nil
}

data, err := readFile("config.json")
if err != nil {
    log.Fatal(err)
}

fmt.Errorf("...: %w", err) wraps an error β€” errors.Is(err, targetErr) and errors.As(err, &target) can unwrap the chain.


🟑 Medior

Interfaces

An interface is a set of method signatures. A type satisfies an interface implicitly β€” no implements keyword.

type Stringer interface {
    String() string
}

type Logger interface {
    Log(level, message string)
}

type ConsoleLogger struct{ prefix string }

func (l ConsoleLogger) Log(level, msg string) {
    fmt.Printf("[%s] %s: %s\n", l.prefix, level, msg)
}

func doWork(logger Logger) {
    logger.Log("info", "starting")
}

doWork(ConsoleLogger{prefix: "app"})

The empty interface any (alias for interface{}) accepts any type. Use it sparingly β€” it loses all type safety.

Goroutines and Channels

A goroutine is a lightweight concurrent function β€” the runtime can run millions simultaneously.

func main() {
    ch := make(chan int, 5) // buffered channel

    go func() {
        for i := range 5 {
            ch <- i * i  // send
        }
        close(ch)
    }()

    for v := range ch {  // receive until closed
        fmt.Println(v)
    }
}

Use channels to communicate. Use mutexes to protect shared state. Prefer channels for synchronization signals, mutexes for protecting data.

select for Multiplexing

select waits on multiple channel operations. It picks randomly when multiple are ready.

func fanIn(c1, c2 <-chan string) <-chan string {
    out := make(chan string)
    go func() {
        defer close(out)
        for {
            select {
            case v, ok := <-c1:
                if !ok { c1 = nil; continue }
                out <- v
            case v, ok := <-c2:
                if !ok { c2 = nil; continue }
                out <- v
            }
            if c1 == nil && c2 == nil { return }
        }
    }()
    return out
}

Context for Cancellation

context.Context propagates cancellation, deadlines, and request-scoped values through an API call chain.

func fetchWithTimeout(url string) ([]byte, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    return io.ReadAll(resp.Body)
}

Always pass ctx as the first parameter to functions that do I/O. Always defer cancel() immediately after WithTimeout or WithCancel to avoid goroutine leaks.

Defer, Panic, and Recover

defer runs a function when the surrounding function returns β€” useful for cleanup.

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

    // ... work with f
    return nil
}

panic unwinds the stack. recover catches it inside a defer. Use this pattern to convert panics into errors at API boundaries:

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

πŸ”΄ Senior

Go Memory Model and Data Races

Go’s memory model defines when writes in one goroutine are guaranteed to be visible to another. The key rule: a happens-before relationship is required.

Any concurrent access to the same memory where at least one access is a write is a data race β€” undefined behavior. The race detector catches them at runtime: go test -race ./....

var mu sync.Mutex
var data []int

func appendSafe(v int) {
    mu.Lock()
    defer mu.Unlock()
    data = append(data, v)
}

// sync.RWMutex for read-heavy workloads:
var rwmu sync.RWMutex
func readSafe() []int {
    rwmu.RLock()
    defer rwmu.RUnlock()
    return data
}

sync.atomic operations (atomic.AddInt64, atomic.LoadInt64, atomic.CompareAndSwapInt64) are lock-free and cheaper than mutexes for simple counters.

sync.Pool for Object Reuse

sync.Pool reduces GC pressure by pooling and reusing allocations.

var bufPool = sync.Pool{
    New: func() any {
        b := make([]byte, 0, 64*1024)
        return &b
    },
}

func processRequest(data []byte) {
    bufp := bufPool.Get().(*[]byte)
    buf := (*bufp)[:0]  // reset length, keep capacity
    defer bufPool.Put(bufp)

    buf = append(buf, data...)
    // process buf
}

The pool is not a cache β€” the GC may clear it at any time. Objects returned from the pool are not zeroed β€” reset manually.

errgroup for Concurrent Work

golang.org/x/sync/errgroup manages a group of goroutines and collects the first error.

func fetchAll(urls []string) ([][]byte, error) {
    g, ctx := errgroup.WithContext(context.Background())
    results := make([][]byte, len(urls))

    for i, url := range urls {
        i, url := i, url  // capture loop variables before Go 1.22
        g.Go(func() error {
            data, err := fetchWithTimeout(ctx, url)
            if err != nil { return err }
            results[i] = data
            return nil
        })
    }

    if err := g.Wait(); err != nil {
        return nil, err
    }
    return results, nil
}

pprof Profiling

pprof is built into the standard library. Add the HTTP handler in development:

import _ "net/http/pprof"
go http.ListenAndServe(":6060", nil)

Then:

go tool pprof http://localhost:6060/debug/pprof/heap   # memory
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30  # CPU

go tool pprof opens an interactive session. top shows the hottest functions. web generates a flame graph (requires Graphviz).

Senior Gotchas

Loop variable capture before Go 1.22 β€” goroutines inside a loop capture the variable by reference, not value. Every goroutine would see the final value of i. Go 1.22 fixed this by giving each iteration its own variable.

Nil interface vs nil pointer β€” an interface value is nil only if both its type and value are nil. A nil pointer of a concrete type assigned to an interface is not nil.

var p *User = nil
var i fmt.Stringer = p   // i is not nil! type = *User, value = nil
fmt.Println(i == nil)    // false

defer in a loop does not run until the function returns β€” this delays file closes, database connections, etc. Move the defer inside a helper function or use an explicit close in the loop body.

Slice backing array sharing: two slices from the same append chain can share backing arrays until a grow triggers a new allocation. Appending to a sub-slice can corrupt the original. Use s[lo:hi:hi] to create a slice with a capped capacity, preventing unintended sharing.