- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You import a Go package to parse a config file. You wrap the call in
the usual if err != nil. The tests pass. Two weeks later a customer
uploads a file with one malformed field, and your whole service dies
with panic: invalid character and a stack trace pointing three
dependencies deep into code you have never read.
The library author had a choice at that line. They could have returned
an error. They chose to panic. Now the failure mode of one bad field
is a crashed process instead of a handled 400.
In a Go library, a panic is a
broken contract. The error return value is the contract. Skipping it
takes a decision that belongs to the caller and makes it for them.
The contract is the signature
Go does not have exceptions. It has two return mechanisms, and they
mean different things.
An error return says: this can fail, and the failure is part of
normal operation. A malformed input, a missing file, a closed
connection. The caller is expected to look at the value and decide.
A panic says: something is so wrong that continuing is meaningless.
A nil pointer you never checked, an index past the end of a slice, an
invariant the program itself violated. It unwinds the stack and, if
nobody recovers, kills the process.
When you write a library, the function signature is a promise. A
signature like this one is a promise that failure comes back as a
value:
func Parse(data []byte) (*Config, error)
If Parse panics on bad input instead of returning an error, you
broke that promise. The caller wrote cfg, err := Parse(data) and
handled the error. They did everything right. Your library crashed
their program anyway.
The standard library holds this line. strconv.Atoi returns an error
on bad input. json.Unmarshal returns an error. os.Open returns an
error. None of them panic because you handed them a string that
happened to be garbage. Garbage input is expected. Expected failures
are values.
What actually deserves a panic
Panic is not banned. It is for the case where continuing would be a
lie, where the program has reached a state that should be impossible.
A programmer error is the clearest case. If a caller passes an
argument that no correct program would ever pass, and the function
cannot produce a meaningful result, a panic surfaces the bug loudly
instead of limping forward with corrupt state.
func NewRing(size int) *Ring {
if size <= 0 {
panic("ring: size must be > 0")
}
return &Ring{buf: make([]int, size)}
}
A zero-size ring buffer is not a runtime condition to recover from. It
is a bug in the calling code. The panic makes it show up in
development, at the exact line that caused it, instead of turning into
a silent divide-by-zero later.
The dividing question is: could a correct program trigger this? If a
user typing the wrong thing into a form can reach the failure, it is
expected, so return an error. If only a bug in the caller's own code
can reach it, a panic is defensible.
regexp draws exactly this line, which brings us to the one place
panic is idiomatic in a public API.
The Must-prefix exception
Look at regexp. It has two constructors:
func Compile(expr string) (*Regexp, error)
func MustCompile(expr string) *Regexp
Compile returns an error, because a regex string can come from
config, from a user, from anywhere. MustCompile panics, because it
exists for one narrow case: a regex literal known at compile time,
assigned to a package-level variable.
var slug = regexp.MustCompile(`^[a-z0-9-]+$`)
If that literal is wrong, the program cannot start, and you want to
know at startup rather than the first time the variable is used. The
panic fires during package initialization. Your tests, or your first
run, catch it immediately.
The Must prefix is a naming convention, and it is a load-bearing
one. It tells the caller: this function panics on failure, use it only
with inputs you control. template.Must, regexp.MustCompile, and
plenty of third-party libraries follow it. The pattern is a thin
wrapper over the error-returning version:
func MustCompile(expr string) *Regexp {
re, err := Compile(expr)
if err != nil {
panic(`regexp: Compile(` +
quote(expr) + `): ` + err.Error())
}
return re
}
Two rules make this safe. First, always provide the error-returning
version too, so callers with dynamic input have a way out. Second,
name it Must so nobody is surprised. A panicking function without
the prefix is a landmine. With the prefix, it is a documented tool.
Converting a panic back to an error at the boundary
Sometimes the panic is not yours. You call into a dependency, or into
your own deep code, and it panics on an input you cannot fully
validate up front. You do not want that panic to escape your package
and crash the caller. So you catch it at the boundary and turn it back
into an error, restoring the contract your signature promised.
recover is the tool. It only works inside a deferred function, and
it stops the unwinding at that point.
func ParseExpr(src string) (result *AST, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf(
"parse %q: %v", src, r)
}
}()
return parseInternal(src), nil
}
Three things make this correct Go. The return values are named, so the
deferred closure can assign to err after the panic unwinds into it.
The recover() call lives directly inside the deferred function, not
in a helper it calls, because recover only sees a panic when called
directly by the deferred function. And the boundary is deliberate: the
panic is contained at the public edge of the package, not swallowed
randomly three layers down.
This is exactly what the standard library does. json.Marshal
recovers from panics raised inside MarshalJSON methods and returns
them as errors, so a bad custom marshaler does not take down your
server. The net/http server recovers from panics in your handlers so
one bad request does not kill the whole listener.
One caution. Do not recover from everything. A nil-pointer dereference
in your own code is a bug, and swallowing it into an error hides the
bug while leaving your program in an unknown state. Recover at a
boundary you understand, for panics you expect from a specific call,
and re-check what you actually caught if you want to be strict:
defer func() {
r := recover()
if r == nil {
return
}
if _, ok := r.(runtime.Error); ok {
panic(r) // a real bug, let it fly
}
err = fmt.Errorf("parse: %v", r)
}()
This lets genuine runtime bugs keep propagating while catching the
deliberate panic("bad token") from your own parser. Where you draw
that line depends on how much you trust the code below you.
The rule, compressed
A library is code other people build on. Its signatures are the
contract. Keep the contract honest.
- Expected failure comes back as an
error. Bad input, missing resource, closed connection. Return it, let the caller decide. - A
panicis for the impossible state, the bug that should never reach production. It is not a control-flow shortcut. - If you offer a panicking convenience, name it
Must, and always ship the error-returning twin next to it. - If a panic can escape your package on an input you cannot fully
validate, catch it at the boundary with
recoverand return it as an error, so the caller'sif err != nilstill works.
The caller wrote the if err != nil. They were ready to handle the
failure. The least a library can do is hand the failure back instead
of the whole process.
A public API is a boundary, and boundaries are where these decisions
live. The Complete Guide to Go Programming goes deep on panic,
recover, and how the runtime unwinds a stack, so you know exactly what
recover catches and what it lets fly. Hexagonal Architecture in Go
takes it up a level and shows where the error-versus-panic line sits
when you draw ports and adapters around code that has to survive
on-call.

Top comments (0)