Day 6 — Panic/Recover & Modules in Depth
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.
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:
- Stops normal execution of the current function
- Runs deferred functions of that function (LIFO)
- Unwinds to the caller, repeating
- 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 initDo 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
recoveronly works inside a deferred function.
- It stops the panic and returns the panic value.
- Outside a panicking flow,
recover()returnsnil.
- 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 unrecoveredThis 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 continueRun 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/day14Implement safe.Call / safe.CallErr as above. Demo:
- Function returns error → pass through
- Function panics string → becomes error
- 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
recoveronly 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.
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.sumTheory 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@abcdef1Theory 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" // RIGHTTheory 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.5raises 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.modrequirement graph + sums
go list -m all # selected versions
go list -m -versions example.com/pkgTheory 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.sumNever hand-edit go.sum. Prefer tidy + get over manual require surgery.
Theory 6 — replace and exclude
Local replace (development)
replace example.com/foo => ../fooOr:
go mod edit -replace example.com/foo=../fooDo not leave local replaces in published modules without reason—they break outsiders.
Fork replace
replace example.com/foo => github.com/you/foo v1.2.3exclude
exclude github.com/bad/lib v1.0.1 // known badTheory 7 — Build list vs your code
go list -f '{{.Path}} {{.Dir}}' ./...
go list -m -json example.com/day15 | headVendor (awareness)
go mod vendor
go build -mod=vendorVendoring 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.3If 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.2package main
import (
"fmt"
"rsc.io/quote"
)
func main() {
fmt.Println(quote.Hello())
}go mod tidy
cat go.mod
head go.sumExample 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/samplerExample 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:
- Did
go.modversions change intentionally?
- Did
go.sumchange with them?
- Any unexpected
replace?
- 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- Add one small external module (
rsc.io/quoteorgolang.org/x/text).
- Use it in code.
- Run
go mod tidy.
- In
NOTES.md, explain every line of yourgo.mod.
- Run
go mod graphand paste a short excerpt.
- 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 tidyConfirm require removed. Contrast with forgetting tidy.
Lab 4 — v2 path quiz (written)
Without necessarily depending on a v2 module, write answers:
- Why does
module .../v2appear in import paths?
- 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,requirelines
- Knows what
go.sumprotects
- Used
go get+go mod tidysuccessfully
- Can read
go mod graph/go mod whylightly
- 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.