Section 09

Error Handling

Python uses exceptions — errors fly up the call stack until a try/except catches them. Go uses explicit error return values. There is no try, no except, no throw. Errors are values you check at every call site.

Python
try:
    data = read_file(path)
    config = parse(data)
    validate(config)
except FileNotFoundError:
    log.error("file missing")
except ValueError as e:
    log.error(f"bad config: {e}")
Go
data, err := readFile(path)
if err != nil {
    return fmt.Errorf("reading: %w", err)
}
config, err := parse(data)
if err != nil {
    return fmt.Errorf("parsing: %w", err)
}
if err := validate(config); err != nil {
    return fmt.Errorf("validating: %w", err)
}

Creating Errors

import "errors"

// Simple error
return errors.New("something broke")

// Formatted error
return fmt.Errorf("user %s not found", id)

// Wrapping (preserves the chain for errors.Is/As)
return fmt.Errorf("loading config: %w", err)  // %w wraps

Custom 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 error type (like isinstance in except blocks)
var nfe *NotFoundError
if errors.As(err, &nfe) {
    fmt.Println("missing:", nfe.Resource, nfe.ID)
}

// Check sentinel error (like catching a specific exception)
if errors.Is(err, os.ErrNotExist) {
    fmt.Println("file doesn't exist")
}

Panic & Recover

Go does have panic — roughly equivalent to raising an unrecoverable exception. It's reserved for programmer errors (index out of bounds, nil dereference), not for expected failures. recover catches panics but should be used sparingly — it's the exception to the rule.

// Panic — for bugs, not business logic
func mustParseURL(raw string) *url.URL {
    u, err := url.Parse(raw)
    if err != nil {
        panic(fmt.Sprintf("invalid URL: %s", raw))
    }
    return u
}

// Recover — catches panic in the same goroutine
defer func() {
    if r := recover(); r != nil {
        log.Printf("recovered: %v", r)
    }
}()
Don't Use Panic for Control Flow

If you find yourself using panic/recover like try/except, stop. Return errors. Panic is for situations where the program cannot continue — corrupted state, impossible invariants. Library functions named MustXxx panic on error by convention.

Section 10

Concurrency

Python has the GIL, asyncio, threading, and multiprocessing — four different concurrency models, each with tradeoffs. Go has goroutines and channels. One model. It works.

Goroutines

A goroutine is a lightweight thread managed by the Go runtime. They cost ~2KB of stack (vs ~8MB for OS threads). You can spawn millions.

Python
import asyncio

async def fetch(url):
    # ... async HTTP call
    return data

async def main():
    results = await asyncio.gather(
        fetch("url1"),
        fetch("url2"),
    )
Go
func main() {
    var wg sync.WaitGroup
    urls := []string{"url1", "url2"}

    for _, url := range urls {
        wg.Add(1)
        go func() {
            defer wg.Done()
            fetch(url)
        }()
    }
    wg.Wait()
}

Channels

Channels are typed conduits for sending values between goroutines. They are the primary synchronization mechanism — Go's alternative to shared memory with locks.

// Unbuffered channel — send blocks until receiver is ready
ch := make(chan string)

go func() {
    ch <- "hello"   // send
}()

msg := <-ch              // receive (blocks until value available)

// Buffered channel — send blocks only when buffer is full
ch := make(chan int, 100)

// Range over channel — reads until closed
for msg := range ch {
    process(msg)
}

Select

select is a switch for channels — blocks until one of the channel operations can proceed.

select {
case msg := <-dataCh:
    process(msg)
case err := <-errCh:
    handleError(err)
case <-time.After(5 * time.Second):
    fmt.Println("timeout")
case <-ctx.Done():
    fmt.Println("cancelled")
    return
}

Common Patterns

// Worker pool
func workerPool(jobs <-chan Job, results chan<- Result, workers int) {
    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobs {
                results <- process(job)
            }
        }()
    }
    wg.Wait()
    close(results)
}
Don't Communicate by Sharing Memory — Share Memory by Communicating

This is Go's concurrency motto. Instead of protecting shared state with sync.Mutex (which works, and is sometimes appropriate), prefer sending data through channels. The channel ensures only one goroutine accesses the data at a time. When you do need a mutex, sync.Mutex and sync.RWMutex work as you'd expect.