Error Handling
Go's explicit error values, wrapping errors, and panic/recover — and when (not) to use them.
Errors are just values
Go has no exceptions for ordinary error handling. Instead, error is a built-in interface, and functions that can fail simply return one as their last value:
type error interface {
Error() string
}
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, 0)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("Result:", result)
}
Error: division by zero
The idiomatic pattern: check immediately
data, err := fetchData()
if err != nil {
return err // or handle it, log it, wrap it — but always check it
}
useData(data)
This "check immediately, don't nest" style keeps the success path at the outer indentation level and error handling explicit and local — a deliberate trade-off against exceptions, which can silently skip many stack frames.
Creating errors with context: fmt.Errorf and %w
func loadConfig(path string) error {
_, err := os.Open(path)
if err != nil {
return fmt.Errorf("loading config from %s: %w", path, err) // %w wraps the original error
}
return nil
}
Wrapping with %w preserves the original error so callers can still inspect it with errors.Is and errors.As, while adding human-readable context at each layer.
errors.Is and errors.As
var ErrNotFound = errors.New("not found")
func findUser(id int) error {
return fmt.Errorf("user %d: %w", id, ErrNotFound)
}
func main() {
err := findUser(42)
if errors.Is(err, ErrNotFound) {
fmt.Println("that user doesn't exist")
}
}
errors.Is walks the chain of wrapped errors looking for a match — essential once you start wrapping errors with %w across multiple layers of a call stack.
Custom error types
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Msg)
}
func validateAge(age int) error {
if age < 0 {
return &ValidationError{Field: "age", Msg: "must not be negative"}
}
return nil
}
err := validateAge(-5)
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Println("Field with problem:", valErr.Field) // age
}
panic and recover — the exception of last resort
panic stops normal execution and unwinds the stack; recover (only useful inside a defer) can stop that unwind. This is reserved for truly unrecoverable situations (programmer bugs, corrupted invariants) — not a substitute for normal error returns:
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 // panics on b == 0 with a runtime error
return
}
Common mistakes
- Using
panic/recoverfor ordinary, expected failure conditions (a missing file, invalid user input) — reserve it for truly exceptional, unrecoverable bugs. - Ignoring an
errorreturn value (data, _ := fetchData()) — a very easy way to hide real failures. - Comparing wrapped errors with
==instead oferrors.Is—==won't see through a%w-wrapped chain.
Interview questions
Q: Why doesn't Go have exceptions like Java or Python? Go's designers chose explicit error return values to make control flow and failure paths visible at every call site, rather than allowing an exception to silently jump across many stack frames. It trades some verbosity for clarity and predictability.
Q: What's the difference between errors.Is and errors.As?
errors.Is checks whether an error (or anything it wraps) matches a specific sentinel error value. errors.As checks whether an error (or anything it wraps) can be unwrapped into a specific type, giving you access to that type's fields.
Q: When is it appropriate to use panic?
Only for programmer errors or truly unrecoverable states (e.g., a required invariant is violated, an impossible code path is reached) — never as a general substitute for returning an error from a function that can fail in expected ways.