When to panic vs Return an Error

Go gives you two very different tools for signaling that something went wrong: returning an error value, and calling panic. Programmers coming from languages with exceptions often reach for panic because it feels familiar, but in idiomatic Go the two mechanisms are not interchangeable. Returning an error is for expected, recoverable failures that are part of a function’s normal contract. Panicking is for truly exceptional situations — broken invariants and programmer bugs — that calling code has no reasonable way to handle. Getting this distinction right is one of the clearest signs of Go fluency.

Overview: how errors and panics actually work

An error in Go is nothing magical — it is a value of an interface type with a single method: Error() string. A function that can fail simply returns an extra result of type error, and the caller checks it with if err != nil. There is no special language support beyond a naming convention and a built-in interface. Because errors are ordinary values, they can be stored, compared, wrapped, logged, or passed along just like any other value, and the compiler forces every call site to at least receive the error (even if a lazy programmer then discards it with _).

panic is different: it is a control-flow mechanism built into the runtime. Calling panic(v) immediately stops normal execution of the current function. Go then starts unwinding the call stack, running any deferred functions along the way, in last-in-first-out order, in each frame it passes through. If none of those deferred functions calls the built-in recover(), the panic reaches the top of the goroutine’s stack, the runtime prints the panic value and a full stack trace to standard error, and the entire program exits with status code 2 — not just the offending goroutine, the whole process. This is why an unrecovered panic anywhere in a Go program, even in a background goroutine, brings the whole application down.

Go deliberately has no try/catch. The language designers chose explicit error returns over exceptions because exceptions create invisible, non-local control flow: any line of code might secretly jump somewhere else on failure, and you cannot tell which without reading every function it transitively calls. An explicit error return makes the failure path visible in the function signature and at every call site. panic exists as an escape hatch for the rarer case where continuing to run is actually more dangerous than crashing — for example, when an internal invariant that the program depends on has been violated, meaning some other part of the program is already broken in a way you cannot safely reason about.

Situation Use
Invalid user input, a missing file, a network timeout, a failed lookup Return an error
A required precondition is violated by the caller (e.g. a nil argument that should never be nil) Either, depending on API contract — often panic if it signals a programming mistake
Startup/initialization failure where the program genuinely cannot proceed (missing required config, a regular expression that fails to compile at package init) panic, often via a Must-style helper
An impossible internal state that indicates a bug in your own code panic
Anything a caller might reasonably want to retry, log, or route to the user Return an error

Syntax

Returning an error follows a simple pattern: the function’s last return value has type error, and the caller checks it immediately after the call.

func doSomething(input string) (Result, error) {
	if input == "" {
		return Result{}, errors.New("input must not be empty")
	}
	// ... compute result ...
	return result, nil
}

panic and recover are built-in functions with these forms:

  • panic(v any) — stops normal execution and begins unwinding the stack, carrying the value v (commonly a string or an error) with it.
  • recover() any — called with no arguments; if the current goroutine is panicking and recover is called directly inside a deferred function, it stops the panic and returns the value passed to panic. Otherwise it returns nil and has no effect.
  • defer statement — schedules a function call to run when the surrounding function returns, whether that return is normal or caused by a panic; this is the only place recover is useful.

Examples

Example 1: returning an error for an expected failure

package main

import (
	"errors"
	"fmt"
)

func divide(a, b float64) (float64, error) {
	if b == 0 {
		return 0, errors.New("division by zero")
	}
	return a / b, nil
}

func main() {
	result, err := divide(10, 2)
	if err != nil {
		fmt.Println("Error:", err)
		return
	}
	fmt.Println("Result:", result)

	result, err = divide(10, 0)
	if err != nil {
		fmt.Println("Error:", err)
		return
	}
	fmt.Println("Result:", result)
}

Output:

Result: 5
Error: division by zero

Dividing by zero is not a bug — it is an entirely predictable input that any caller of divide might pass. Returning an error lets the caller decide what to do: retry with different numbers, show a message, or abort. Nothing about the program is broken; a value was simply rejected.

Example 2: panic for an unrecoverable startup condition (the Must pattern)

package main

import (
	"fmt"
	"os"
)

func mustEnv(key string) string {
	value, ok := os.LookupEnv(key)
	if !ok {
		panic(fmt.Sprintf("required environment variable %q is not set", key))
	}
	return value
}

func main() {
	os.Setenv("APP_NAME", "orders-service")
	name := mustEnv("APP_NAME")
	fmt.Println("Starting service:", name)
}

Output:

Starting service: orders-service

This mirrors the standard-library convention seen in functions like regexp.MustCompile and template.Must: at program startup, if a required piece of configuration is missing, there is no sensible way to keep running — every later operation would just fail in confusing ways. Panicking immediately, with a clear message, fails fast and loudly instead of limping along. This is appropriate specifically because it runs once, at initialization, outside of any per-request or per-call path.

Example 3: recovering from an unexpected panic at a package boundary

package main

import (
	"fmt"
)

func safeDivide(a, b int) (result int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("recovered from panic: %v", r)
		}
	}()
	result = a / b
	return result, nil
}

func main() {
	result, err := safeDivide(10, 2)
	if err != nil {
		fmt.Println("Error:", err)
	} else {
		fmt.Println("Result:", result)
	}

	result, err = safeDivide(10, 0)
	if err != nil {
		fmt.Println("Error:", err)
	} else {
		fmt.Println("Result:", result)
	}
}

Output:

Result: 5
Error: recovered from panic: runtime error: integer divide by zero

Integer division by zero is one of Go’s built-in runtime panics (unlike floating-point division, which produces +Inf). Here safeDivide uses a deferred function with recover() to catch that panic and convert it into a normal error return, so the caller never sees a crash. This pattern is legitimate at API boundaries where you cannot fully control what happens inside (for example, a plugin system calling untrusted code), but note that the better fix for this specific, known failure mode is still to check b == 0 explicitly and return an error directly — recover is a safety net for panics you did not anticipate, not a substitute for validating input you know about.

How it works step by step

When panic(v) executes inside a function F:

  • Execution of F stops immediately at that point; no further statements in F run.
  • Any functions F deferred (with defer) still run, in reverse order of how they were deferred, even though F is panicking.
  • If one of those deferred functions calls recover() directly (not through another function it calls), the panic stops right there. The panicking function then returns normally to its caller — execution does not jump back to where panic was called, and the function’s named return values (if any) can still be set from inside the deferred function, exactly as safeDivide does above.
  • If no deferred function recovers, the runtime moves up to F‘s caller, runs any deferred functions there, and repeats the same check — the panic keeps propagating up the call stack, one frame at a time.
  • If it reaches the top of the goroutine without being recovered, the Go runtime prints the panic value and a stack trace to standard error and terminates the entire process with exit status 2, regardless of which goroutine panicked.

Common Mistakes

Mistake 1: panicking for an expected, ordinary failure

Using panic for input validation forces every caller to either crash or wrap the call in recover, which fights against Go’s normal error-handling idiom and hides the failure path from the function signature.

func ParseAge(s string) int {
	n, err := strconv.Atoi(s)
	if err != nil {
		panic("invalid age: " + s)
	}
	return n
}

Any caller that passes untrusted input (which is the whole point of a parsing function) can now crash the program. Return an error instead, so the caller decides what happens:

package main

import (
	"fmt"
	"strconv"
)

func ParseAge(s string) (int, error) {
	n, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("invalid age %q: %w", s, err)
	}
	if n < 0 || n > 150 {
		return 0, fmt.Errorf("age %d out of range", n)
	}
	return n, nil
}

func main() {
	age, err := ParseAge("42")
	if err != nil {
		fmt.Println("Error:", err)
		return
	}
	fmt.Println("Parsed age:", age)

	_, err = ParseAge("banana")
	if err != nil {
		fmt.Println("Error:", err)
	}
}

Output:

Parsed age: 42
Error: invalid age "banana": strconv.Atoi: parsing "banana": invalid syntax

The %w verb wraps the underlying error so callers can still inspect the original cause with errors.Is or errors.As, while the message stays human-readable.

Mistake 2: calling recover() indirectly and expecting it to work

recover() only has an effect when it is called directly inside a deferred function — not inside some other function that the deferred function happens to call. This is a subtle but well-documented rule, and it is easy to break by refactoring a recover call into a helper.

func doWork() {
	defer func() {
		logAndRecover()
	}()
	panic("boom")
}

func logAndRecover() {
	if r := recover(); r != nil {
		fmt.Println("recovered:", r)
	}
}

Here the deferred function is the anonymous closure, and it calls logAndRecover, which calls recover() one level too deep. That recover() call is not direct, so it returns nil, the panic is never stopped, and the program still crashes. The fix is to call recover() directly inside the deferred function itself:

package main

import "fmt"

func doWork() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("recovered:", r)
		}
	}()
	panic("boom")
}

func main() {
	doWork()
	fmt.Println("program continues normally")
}

Output:

recovered: boom
program continues normally

Best Practices

  • Default to returning an error; treat panic as the exception to the rule, not the norm.
  • Reserve panic for programmer bugs and violated invariants — situations where continuing would be unsafe, not merely inconvenient.
  • Use the Must-prefix naming convention (like regexp.MustCompile) for helpers that panic, so callers know at a glance that failure is fatal, and reserve them for initialization code, not request-handling paths.
  • Never let an unexpected panic cross a library’s public API silently; if you must guard against it, recover at the boundary and convert it into a returned error.
  • Wrap errors with fmt.Errorf("...: %w", err) to preserve context while keeping the original error inspectable.
  • Every goroutine you launch is a separate crash risk — an unrecovered panic in any goroutine takes down the whole program, so long-running goroutines that call into less-trusted code should recover internally.
  • Never use panic/recover as a general substitute for if err != nil; it obscures control flow and is slower and harder to reason about than an explicit check.
  • Document in a function’s comment when it may panic, since Go’s type system gives no compile-time signal of this the way a checked exception would.

Practice Exercises

  • Write a function SafeIndex(s []int, i int) (int, error) that returns the element at index i or a descriptive error instead of letting an out-of-range index panic. Test it with an index that is too large and confirm you get an error, not a crash.
  • A configuration loader panics if a required JSON config file is missing, using a mustLoadConfig helper called once at program startup. An HTTP handler later in the same program panics if a request is missing a required header. Explain, in your own words, why the first panic is reasonable and the second one is not, and rewrite the handler to return an error (or an HTTP error response) instead.
  • Take the broken doWork/logAndRecover example from Common Mistakes and, without moving the recover() call, find a different way to make it actually stop the panic. (Hint: think about what “directly inside a deferred function” allows if the deferred function itself is what calls recover versus delegates it.)

Summary

  • error is a plain interface value used for expected, recoverable failures; the caller checks it explicitly with if err != nil.
  • panic stops normal execution, unwinds the stack running deferred functions, and crashes the whole program if nothing calls recover().
  • Use panic for programmer bugs, violated invariants, and unrecoverable startup failures — not for ordinary, expected error conditions like bad input.
  • The Must-prefix convention marks functions that panic on failure and is meant for initialization code, following the pattern of standard-library functions like regexp.MustCompile.
  • recover() only stops a panic when called directly inside a deferred function — calling it from a helper function one level deeper does nothing.
  • An unrecovered panic in any goroutine terminates the entire process, not just that goroutine, so long-lived goroutines should have a recovery plan.
  • When in doubt, return an error — it keeps failure handling explicit, local, and part of your function’s documented contract.