skip to content

In Go, when should a function panic instead of returning an error?

level: juniorimportance: must knowfreq 80%

answer

  1. ask who is supposed to fix it
  2. expected situation versus programmer bug
  3. caller-controlled data never crashes
  4. impossible state, or setup before main
  5. libraries are stricter than main

basics

~20 s

Panic only for bugs the program cannot continue past: impossible internal states, broken invariants, or setup at package init. Anything a caller could reasonably hit at runtime, including bad input, missing files and I/O failure, returns an error.

solid answer

~40 s

The deciding question is who is supposed to fix it. If the caller, its user, or an operator could hit the situation and act on it, return an `error` — that covers bad input, a missing file, a malformed config line, a dead network peer. If reaching the line means my own code is wrong and any value I return would be a lie, `panic` — a corrupted enum, a field a constructor was supposed to fill, or a resource that must exist before `main` runs, which is what the `Must` helpers are for. Libraries are stricter than programs: a `main` package may decide to stop because it is the program, but an exported function is a guest in someone else's process, so it must not turn caller-supplied data into a crash.

code

go · 16 lines
go
// Exported: the caller supplies the pattern, so a bad one is an error.
func NewRule(name, pattern string) (*Rule, error) {
	re, err := regexp.Compile(pattern)
	if err != nil {
		return nil, fmt.Errorf("rule %q: %w", name, err)
	}
	return &Rule{Name: name, pattern: re}, nil
}

// Unexported invariant: reaching this means the loader is buggy, not the user.
func (r *Rule) mustPattern() *regexp.Regexp {
	if r.pattern == nil {
		panic("rule: pattern is nil after NewRule")
	}
	return r.pattern
}

go deeper

for a junior

Be ready to state the rule and give one example of each side: a missing file returns an error, a corrupted internal state panics. Knowing that a panic ends the program unless something stops it is the key fact.

for a middle

Explain the boundary rule as well as the principle: exported functions do not panic on caller data, and package-level initialization is the one place a panic is routine. Name the Must helpers as the sanctioned form.

for a senior

Show the production consequence. A panic on an exported path is a restart rather than a failed request, so demonstrate how you validate at the entry point and keep panics confined to unexported invariants.

for a principal

Own the rule as package policy rather than personal taste: which exceptions are allowed, who reviews them, and how strictness scales with how many teams import the package.

### The one-line rule In Go, an **error** is a value you return to say "this attempt did not work, here is why" — the caller is expected to read it and decide what to do. A **panic** aborts the current call, runs the deferred functions on the way out, and, unless something deliberately stops it, kills the whole process. So the choice between them is not a style question. It is a question about **who is supposed to fix the situation**. - If the *caller* — or the caller's user, or the operator — could plausibly hit this and could plausibly do something about it, **return an error**. - If reaching this line means *your own code is wrong* and any value you could return would be a lie, **panic**. ### What counts as "expected failure" Almost everything a real program meets at runtime is expected failure, even though it feels exceptional in the moment: a file that is not there, a malformed configuration line, a regular expression an operator typed wrong, a JSON body that does not match the struct, a network peer that went away, a number that will not parse, a permission that was denied. None of those mean your code is broken; they mean the world is the way it is. Every one of them returns an error in Go's own standard library, and yours should too. That is why `os.Open` returns `(*os.File, error)` and `strconv.Atoi` returns `(int, error)`, and why the idiomatic Go you write is a spine of `if err != nil { return ..., err }` down the middle of the function. The verbosity is the price of the guarantee that no failure is invisible. ### What counts as "impossible" A panic is for a state that your own code says cannot happen: - A `switch` over an enum you defined, reaching a `default` that means the value was corrupted. - A struct field that a constructor is supposed to have filled and did not. - A programmer contract you documented and the caller broke in a way you cannot express in the type system — for instance calling a method on a value the package requires you to build with its constructor. - **Setup at package initialization.** If a program cannot be correct without a resource that must exist before `main` runs — an embedded template that must parse, a regular expression written as a literal in your source — failing loudly at process start is better than serving traffic wrongly. This is the situation the `Must` helpers exist for. The common shape of a defensible panic is that its *message names a bug*, not a bad input: `panic("rule: pattern is nil after NewRule")` tells whoever reads the crash which invariant broke. ### The library rule, which is stricter The further your code is from the process that owns the crash, the less right you have to panic. A `main` package can decide to stop; it *is* the program. A package that other people import is a guest in someone else's process, and a panic from an exported function is a crash the importer cannot see in your signature, did not agree to, and often cannot prevent. So the working rule for any exported function is: **do not panic on anything the caller controls.** If a caller can reach it by passing a value, it is an error return. This is also why moving a `Must` call from a package-level variable into a function that takes caller data is a real defect rather than a nitpick. As a package-level variable the argument is a literal you wrote, so a mistake crashes at startup, in your own tests, before anything is serving. Inside a request path the argument is data, and the same helper turns a bad input into a crash. ### What a panic still is not It is not exception-based control flow. Go has no `catch`; the only way to stop a panic is a deferred call that recovers, and that is deliberately awkward because it is meant to be rare. Using panic to jump out of deep recursion inside one unexported function is an accepted trick — the panic never leaves the package and is converted to an error at the exported edge — but it is a local optimisation, not a design. ### What the rule costs, and why it is still worth it The honest cost is plumbing: an error must be returned, wrapped with context (`fmt.Errorf("...: %w", err)`), and returned again at every level. In exchange, every caller reading your signature knows exactly which calls can fail, and a failure in production produces a message rather than a stack trace and a restart. A test suite can then assert on the failure instead of asserting that the process died. A useful summary to say out loud: *errors are for situations, panics are for bugs, and an exported function must not turn someone else's input into your bug.*

  • Does the rule differ between a main package and a library other teams import?
    Yes, and it is the most useful refinement. A `main` package owns the process, so aborting at startup on unusable configuration is a defensible choice. An imported package is a guest: a panic on its exported path is a crash the importer never agreed to and cannot see in the signature, so it must return errors instead.
  • Is using panic to unwind out of a deep recursive parser ever acceptable?
    Yes, as a contained implementation trick. Some standard-library parsers panic internally to escape deep recursion and convert the value back into an error in a deferred function at the package's exported edge. The rule holds: the panic never leaves the package, and the exported signature still returns an error.
  • A helper is only ever called from inside your own package with fixed arguments. May it panic?
    Yes. If every call site is code you own and a failure means the package is internally inconsistent, panicking with a message naming the broken invariant is better than returning an error nobody can act on. Keep it unexported so no caller can reach it with arbitrary data.

An error is a delivery note saying the parcel would not fit through the door. A panic is pulling the fire alarm: correct when the building is actually burning, and destructive every other time.

saying these in an interview costs you the question

  • Treats panic as Go's exception mechanism for ordinary failures
  • Panics on invalid arguments because returning errors is boilerplate
  • Says panicking is safe because the caller can just recover
  • Panics from an exported function on data the caller supplied
  • Returns an error for a state that is genuinely impossible, then ignores it