Panic vs Returning an Error in Go
When a Go function should return an error and when it should panic, what a panic does to your program, and how to turn a panic into an error safely.
Go has two ways to signal that something went wrong. The everyday way is returning an error. The other is panic, which stops normal execution. Beginners coming from languages with exceptions often reach for panic because it feels like throw. In Go it is meant for a much narrower set of situations.
What panic Does
panic stops the current function immediately, runs any deferred calls, and then does the same in each caller, all the way up. If nothing stops it, the program crashes and prints the panic message with a stack trace.
package main
import "fmt"
func main() {
defer fmt.Println("deferred call still runs")
fmt.Println("before")
panic("something impossible happened")
}
Output, followed by a stack trace and exit status 2:
before
deferred call still runs
panic: something impossible happened
goroutine 1 [running]:
main.main()
The runtime also panics on its own for programming mistakes. These are the ones you will meet most often, with the exact text you will see:
panic: runtime error: index out of range [5] with length 3
panic: runtime error: invalid memory address or nil pointer dereference
panic: assignment to entry in nil map
panic: runtime error: integer divide by zero
panic: interface conversion: interface {} is string, not int
Each of these means there is a bug in the code, not a problem with the input or the environment.
The Rule: Expected Failures Return an Error
A failure is expected when it can happen even if your code is correct. Files go missing, networks drop, users type nonsense, databases time out. All of these should be returned as error values so the caller can decide what to do.
package main
import (
"fmt"
"strconv"
)
func parseQuantity(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("quantity %q: %w", s, err)
}
if n <= 0 {
return 0, fmt.Errorf("quantity %d must be positive", n)
}
return n, nil
}
func main() {
for _, in := range []string{"3", "zero", "-1"} {
n, err := parseQuantity(in)
if err != nil {
fmt.Println("rejected:", err)
continue
}
fmt.Println("accepted:", n)
}
}
Output:
accepted: 3
rejected: quantity "zero": strconv.Atoi: parsing "zero": invalid syntax
rejected: quantity -1 must be positive
The program keeps running and handles each input. If parseQuantity panicked instead, one bad line in a file would crash the whole process.
Panicking on bad input crashes servers:
A panic triggered by user input in an HTTP handler is recovered by net/http for that one request, but in a background goroutine an unrecovered panic kills the entire program, taking down every other request with it.
When panic Is the Right Choice
Panic is appropriate when continuing would be wrong and the cause is a bug or a broken setup, not a normal runtime condition:
- Impossible states. A
switchreached a case that your own code guarantees cannot happen. - Startup setup that must succeed. A hard-coded regular expression or template that fails to compile is a bug in your source code.
- Misuse of an API by the programmer, such as passing a negative size where the docs forbid it.
The standard library follows the Must naming convention for the startup case. regexp.MustCompile panics if the pattern is invalid, which is fine for a pattern written in your source code:
package main
import (
"fmt"
"regexp"
)
var pinCode = regexp.MustCompile(`^[1-9][0-9]{5}$`)
func main() {
fmt.Println(pinCode.MatchString("110001"))
fmt.Println(pinCode.MatchString("012345"))
}
This prints true and false. If you mistyped the pattern, the program would panic the moment it starts, which you would notice right away during development. For a pattern typed by a user, you would call regexp.Compile and handle its error instead.
Writing Your Own Must Helper
You can follow the same idea for your own setup code:
package main
import (
"fmt"
"strconv"
)
func mustAtoi(s string) int {
n, err := strconv.Atoi(s)
if err != nil {
panic(fmt.Sprintf("mustAtoi(%q): %v", s, err))
}
return n
}
const defaultWorkers = "4"
func main() {
workers := mustAtoi(defaultWorkers)
fmt.Println("workers:", workers)
}
This prints workers: 4. Use Must helpers only on values you control, like constants and embedded files. Never pass request data or file contents to them.
Turning a Panic Into an Error With recover
recover stops a panic, but only when called inside a deferred function. You can use it at a boundary, such as around a plugin or a third-party parser, to convert a crash into a normal error:
package main
import (
"fmt"
)
func riskyParse(data []int) int {
return data[10] // panics if data is short
}
func safeParse(data []int) (n int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("parse failed: %v", r)
}
}()
return riskyParse(data), nil
}
func main() {
_, err := safeParse([]int{1, 2, 3})
fmt.Println(err)
}
Output:
parse failed: runtime error: index out of range [10] with length 3
The named result err lets the deferred function replace the return value after the panic is stopped.
recover is not try/catch:
Wrapping every function in recover hides real bugs and leaves data half-updated. Use it at clear boundaries, such as the top of a goroutine or around code you do not control, and still log the panic so the bug gets fixed.
Quick Decision Guide
Can this happen when the code is correct? -> return an error
Is it caused by input, files, network, DB? -> return an error
Is it a bug or impossible state? -> panic
Is it setup from constants at startup? -> panic via a Must helper
Library code called by others? -> almost always return an error
A healthy balance:
In a typical Go codebase, you will find return ..., err hundreds of times and panic only a handful of times, mostly in Must helpers and impossible default cases. If your code looks like that, you have the balance right.
Related guides:
Summary
Return an error for anything that can go wrong in a correct program: bad input, missing files, network and database failures. Panic only for bugs, impossible states and startup setup from values you control, usually through a Must helper. recover inside a deferred function can turn a panic into an error at a boundary, but it is a safety net, not a replacement for returning errors.