Day 6 — Panic/Recover & Modules in Depth

Updated

July 30, 2025

Day 6 — Panic/Recover & Modules in Depth

Day 14 — Panic & recover

Stage II · ~3h (theory-heavy)
Goal: Know when panic is justified, how stack unwinding and recover work, and how to contain panics at process or request boundaries—without using panic as a substitute for error.

Important

Idiomatic Go: return error for expected failure. Panic for programming mistakes or truly unrecoverable situations. Recover at boundaries (main, HTTP middleware, worker supervisor)—not scattered through business logic.

Why this day exists

Every Go developer eventually:

  • Hits a nil pointer panic in production
  • Wonders whether to recover
  • Sees libraries that panic on programmer misuse (MustCompile)

Without a policy, recover becomes exception handling by another name—and error chains (Day 13) stop working. Day 14 draws hard lines.


Theory 1 — What panic does

panic("something impossible happened")
panic(err)
panic(fmt.Errorf("invariant broken: %d", n))

Panic:

  1. Stops normal execution of the current function
  2. Runs deferred functions of that function (LIFO)
  3. Unwinds to the caller, repeating
  4. If never recovered, process crashes with stack trace

Panic values

Any value can be paniced; conventionally a string or error. Recover returns that value as any.


Theory 2 — When panic is acceptable

Acceptable Example
Impossible state / violated invariant panic("unreachable") after exhaustive switch you believe complete
Programmer API misuse in init-time helpers template.Must, regexp.MustCompile
Truly unrecoverable corruption Continuing would worsen data loss
Not acceptable Prefer
File not found error
Bad user input error
Network timeout error
“I don’t want to thread error returns” Thread them anyway

Must pattern

func MustCompile(expr string) *regexp.Regexp {
    re, err := regexp.Compile(expr)
    if err != nil {
        panic(err)
    }
    return re
}

var re = regexp.MustCompile(`^\d+$`) // OK: fixed string at init

Do not MustCompile user-supplied patterns in a request path without recovery—or better, return error.


Theory 3 — recover

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

Rules

  1. recover only works inside a deferred function.
  2. It stops the panic and returns the panic value.
  3. Outside a panicking flow, recover() returns nil.
  4. Recovering in an inner function that is not deferred does nothing useful for the outer panic.
func wrong() {
    if r := recover(); r != nil { // almost always nil here
        // ...
    }
    panic("x")
}

Deferred still runs during panic

func f() {
    defer fmt.Println("cleanup")
    panic("boom")
}
// prints cleanup, then process dies if unrecovered

This is why defer mu.Unlock() still unlocks on panic—usually desirable.


Theory 4 — Boundaries, not blankets

Good: contain at the edge

func main() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Fprintf(os.Stderr, "fatal panic: %v\n", r)
            os.Exit(2)
        }
    }()
    if err := run(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

Good: HTTP middleware (preview)

func recoverMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                // log stack: debug.Stack()
                http.Error(w, "internal error", 500)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

One request panics → others continue.

Bad: recover inside every domain function

Hides bugs, produces empty error semantics, duplicates policy.


Theory 5 — Converting panic to error

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

Use for untrusted plugins or transitional code—not as default control flow.

Re-panic when you should not handle

defer func() {
    if r := recover(); r != nil {
        if !isBenign(r) {
            panic(r) // continue unwinding
        }
        // handle benign
    }
}()

Theory 6 — Stack traces

import "runtime/debug"

defer func() {
    if r := recover(); r != nil {
        fmt.Fprintf(os.Stderr, "panic: %v\n%s\n", r, debug.Stack())
    }
}()

Log stacks at boundaries; do not print stacks for ordinary error returns.


Theory 7 — Goroutines and panics (preview)

A panic in a goroutine does not recover in another goroutine’s defer. Unrecovered panic in any goroutine crashes the process.

go func() {
    defer func() {
        if r := recover(); r != nil {
            // only protects this goroutine
            log.Println(r)
        }
    }()
    work()
}()

Stage III will revisit supervision. Today: know panics are per-goroutine for recovery.


Worked examples bank

Example A — Invariant panic

func abs(n int) int {
    if n >= 0 {
        return n
    }
    if n < 0 {
        return -n
    }
    panic("unreachable")
}

Example B — SafeCall wrapper

package safe

import "fmt"

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

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

Example C — Must for static data only

var goVersionRE = regexp.MustCompile(`^go1\.\d+`)

func ParseUserRegexp(expr string) (*regexp.Regexp, error) {
    return regexp.Compile(expr) // user input: return error
}

Example D — Demonstrate defer order with panic

func demo() {
    defer fmt.Println("1")
    defer fmt.Println("2")
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recovered", r)
        }
    }()
    defer fmt.Println("3")
    panic("x")
}
// prints 3, recovered x, 2, 1 — recover in its defer stops panic; remaining defers continue

Run and verify understanding of order.

Example E — Do not use panic for EOF

// wrong
func readAll(r io.Reader) []byte {
    b, err := io.ReadAll(r)
    if err != nil {
        panic(err)
    }
    return b
}

// right
func readAll(r io.Reader) ([]byte, error) {
    return io.ReadAll(r)
}

Labs

Suggested workspace: ~/lab/90daysofx/go/day14

Lab 1 — SafeCall package

mkdir -p ~/lab/90daysofx/go/day14
cd ~/lab/90daysofx/go/day14
go mod init example.com/day14

Implement safe.Call / safe.CallErr as above. Demo:

  1. Function returns error → pass through
  2. Function panics string → becomes error
  3. Function panics error value → wrapped or formatted

Lab 2 — Boundary recover in main

Wire run() such that an intentional panic in a deep helper is recovered only in main (or only in safe.Call at the top). Business helpers do not recover.

Lab 3 — Policy document

Write PANIC_POLICY.md (short):

  • When your project panics
  • Where recover is allowed
  • Explicit ban list (validation, I/O, …)

Lab 4 — Must misuse demo

Show regexp.MustCompile with invalid pattern crashes process. Then fix with Compile + error for a CLI flag pattern.


Common gotchas

Gotcha Fix
recover not in defer Always defer
Recover everywhere Boundaries only
Panic for normal errors Return error
Swallowing panic without log Log + stack
Assuming recover catches other goroutines It does not
Must* on user input Return error
Forgetting defers run on panic Rely on them for unlock/close

Checkpoint

  • Panic vs error policy stated
  • recover only in defer demonstrated
  • SafeCall converts panic → error
  • No domain validation via panic
  • Know unrecovered panic kills process
  • Goroutine recovery isolation understood

Commit

git add .
git commit -m "day14: panic recover policy + safe.Call"

Tomorrow

Day 15 — Modules deep dive: go.mod / go.sum literacy, semantic import versioning, go mod tidy, replace, and explaining versions like a code reviewer.


Day 15 — Modules deep dive

Stage II · ~3h (theory-heavy)
Goal: Read and maintain go.mod / go.sum with confidence—versions, require / exclude / replace, go mod tidy, minimal version selection (MVS), and semantic import paths—without cargo-culting commands.

Note

Baseline: Go 1.26.x. The go directive in go.mod declares the language/toolchain baseline your module expects. With GOTOOLCHAIN=auto, newer toolchains may download to match—know your policy.

Why this day exists

Modules are how Go code is shared and versioned. Confusion here looks like:

  • “It builds on my machine” with mystery replace
  • Broken import paths for v2+ modules
  • Bloated or incomplete go.sum
  • Accidental major upgrades

Day 15 makes you literate enough to review dependency changes in PRs.


Theory 1 — Anatomy of go.mod

module example.com/day15

go 1.26

require (
    golang.org/x/text v0.21.0
)

require (
    // indirect dependencies listed after tidy
    golang.org/x/tools v0.28.0 // indirect
)

exclude example.com/bad v1.2.3

replace example.com/foo => ../foo
Directive Role
module Module path prefix for packages
go Minimum Go language version for this module
require Direct (and after tidy, indirect) deps with versions
exclude Forbid a specific version
replace Local or fork override (dev / emergency)
retract Author marks bad released versions

go.sum

Checksums of module contents (and go.mod of deps). Commit go.sum. It protects against tampering and unexpected content changes.

go mod verify   # check cached modules against go.sum

Theory 2 — Versions and pseudo-versions

Semantic versions

v1.2.3
v1.2.4-rc.1

Tags on the dependency’s repository drive versions.

Pseudo-versions

When you depend on an untagged commit:

v0.0.0-20250102150405-abcdef123456

Readable as: base version + timestamp + commit hash prefix.

go get example.com/pkg@master
go get example.com/pkg@abcdef1

Theory 3 — Semantic import versioning

For modules at v2 and above, the import path includes the major version suffix:

module example.com/widget/v2

import "example.com/widget/v2/foo"
Major Module path form
v0/v1 example.com/widget
v2+ example.com/widget/v2

Why: Go treats import path as identity. Different majors can coexist as different paths.

Common breakages

// go.mod says module example.com/widget/v2
import "example.com/widget/foo" // WRONG
import "example.com/widget/v2/foo" // RIGHT

Theory 4 — Minimal version selection (MVS)

When building a module, Go selects the minimum version of each dependency that satisfies all require constraints—not the newest possible.

Implications:

  • Adding a dependency that needs lib@v1.5 raises the build to at least v1.5
  • You do not automatically jump to latest v1.9 unless something requires it
  • Reproducible builds depend on the go.mod requirement graph + sums
go list -m all          # selected versions
go list -m -versions example.com/pkg

Theory 5 — Everyday commands

Command Purpose
go mod init Create module
go get pkg@version Add/upgrade/downgrade
go get pkg@none Remove require
go mod tidy Match requires to imports; prune unused; fill sums
go mod download Prefetch modules
go mod graph Requirement graph
go mod why pkg Why is this in the build?
go clean -modcache Nuclear cache clear (slow rebuilds)

go mod tidy ritual

After adding/removing imports:

go mod tidy
git diff go.mod go.sum

Never hand-edit go.sum. Prefer tidy + get over manual require surgery.


Theory 6 — replace and exclude

Local replace (development)

replace example.com/foo => ../foo

Or:

go mod edit -replace example.com/foo=../foo

Do not leave local replaces in published modules without reason—they break outsiders.

Fork replace

replace example.com/foo => github.com/you/foo v1.2.3

exclude

exclude github.com/bad/lib v1.0.1 // known bad

Theory 7 — Build list vs your code

go list -f '{{.Path}} {{.Dir}}' ./...
go list -m -json example.com/day15 | head

Vendor (awareness)

go mod vendor
go build -mod=vendor

Vendoring copies deps into vendor/. Useful for some enterprise/airgap workflows; optional for this volume. Prefer modules cache for learning.

GOTOOLCHAIN

go env GOTOOLCHAIN
# auto | local | go1.26.3

If go.mod requests a newer Go than installed, auto may download a toolchain. For class consistency, install 1.26.x locally.


Worked examples bank

Example A — Fresh module with a real dep

mkdir -p ~/lab/90daysofx/go/day15
cd ~/lab/90daysofx/go/day15
go mod init example.com/day15
go get rsc.io/quote@v1.5.2
package main

import (
    "fmt"

    "rsc.io/quote"
)

func main() {
    fmt.Println(quote.Hello())
}
go mod tidy
cat go.mod
head go.sum

Example B — Explain indirect

After tidy, transitive modules appear as // indirect. They are required by your direct deps, not imported by your packages.

Example C — Upgrade and why

go get rsc.io/quote@latest
go list -m all
go mod why rsc.io/sampler

Example D — replace demo

mkdir -p /tmp/hellofork
# create a tiny module that your app replaces to
go mod edit -replace rsc.io/quote=/tmp/hellofork

(Only as a learning exercise; restore with go mod edit -dropreplace.)

Example E — Reading a PR diff

When reviewing:

  1. Did go.mod versions change intentionally?
  2. Did go.sum change with them?
  3. Any unexpected replace?
  4. Major version path changes in imports?

Labs

Suggested workspace: ~/lab/90daysofx/go/day15

Lab 1 — Module literacy notebook

mkdir -p ~/lab/90daysofx/go/day15
cd ~/lab/90daysofx/go/day15
go mod init example.com/day15
  1. Add one small external module (rsc.io/quote or golang.org/x/text).
  2. Use it in code.
  3. Run go mod tidy.
  4. In NOTES.md, explain every line of your go.mod.
  5. Run go mod graph and paste a short excerpt.
  6. Run go mod why <some-indirect> if any.

Lab 2 — Version bump exercise

go list -m -versions rsc.io/quote
go get rsc.io/quote@<older>
go test ./...   # or go run .
go get rsc.io/quote@<newer>

Document what changed in go.mod/go.sum.

Lab 3 — remove dependency cleanly

Delete the import, then:

go mod tidy

Confirm require removed. Contrast with forgetting tidy.

Lab 4 — v2 path quiz (written)

Without necessarily depending on a v2 module, write answers:

  1. Why does module .../v2 appear in import paths?
  2. Can v1 and v2 of the same logical project coexist in one build?

Common gotchas

Gotcha Fix
Not committing go.sum Always commit
Local replace left in repo Drop before publish
Wrong import for v2 module Include /v2
Hand-editing go.sum Use go mod tidy / go get
Assuming newest deps always selected MVS picks minimum satisfying
Ignoring go directive Match team toolchain (1.26 here)
GOPATH mode myths Modules are default

Checkpoint

  • Can explain module, go, require lines
  • Knows what go.sum protects
  • Used go get + go mod tidy successfully
  • Can read go mod graph / go mod why lightly
  • Understands replace risks
  • Semantic import versioning for v2+ stated correctly

Commit

git add .
git commit -m "day15: modules go.mod go.sum literacy"

Tomorrow

Day 16 — Table-driven tests: testing package, t.Run subtests, helpers, fixtures, and turning validation/domain code into a regression suite.