Go Error Handling
Overview
Go handles errors as values, not exceptions. There's no try/catch — instead, functions return an error as the last return value, and callers check it explicitly. This makes error paths visible, forces you to think about failures, and keeps control flow linear and readable.
The error Interface
error is a built-in interface with a single method. Any type with an Error() string method satisfies it.
type error interface {
Error() string
}
Convention: Return error as the last return value. Return zero/nil values for other results when an error occurs.
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("divide: b cannot be zero")
}
return a / b, nil
}
result, err := divide(10, 2)
if err != nil {
log.Fatal(err) // or return/handle
}
fmt.Println(result) // 5
Creating Errors
errors.New — simple static message
Best for sentinel errors that are compared by identity.
import "errors"
var ErrNotFound = errors.New("not found")
var ErrUnauthorized = errors.New("unauthorized")
func findUser(id int) (*User, error) {
if id <= 0 {
return nil, ErrNotFound
}
// ...
}
// Caller checks
if errors.Is(err, ErrNotFound) {
// handle "not found" specifically
}
fmt.Errorf — formatted message with context
Use this to add context as you propagate errors up the call stack. Use %w to wrap the original error so it can still be inspected.
func getUser(id int) (*User, error) {
user, err := db.QueryUser(id)
if err != nil {
return nil, fmt.Errorf("getUser %d: %w", id, err)
}
return user, nil
}
// Error message reads: "getUser 42: not found"
Custom Error Types
Define a struct implementing Error() string when you need to carry extra data with the error.
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on field %q: %s", e.Field, e.Message)
}
func validateAge(age int) error {
if age < 0 || age > 150 {
return &ValidationError{Field: "age", Message: "must be between 0 and 150"}
}
return nil
}
err := validateAge(-5)
if err != nil {
fmt.Println(err)
// validation failed on field "age": must be between 0 and 150
}
Error Wrapping and Unwrapping
Wrapping lets you add context while preserving the original error for inspection.
// Wrap with context
err := fmt.Errorf("handler: %w", ErrNotFound)
// errors.Is — checks if error chain contains target (works through wraps)
errors.Is(err, ErrNotFound) // true
// errors.As — extracts a specific type from the error chain
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Println("field:", valErr.Field)
}
// errors.Unwrap — get the directly wrapped error
inner := errors.Unwrap(err)
Wrapping chain example:
// db error → repo error → handler error
dbErr := errors.New("connection refused")
repoErr := fmt.Errorf("repo.GetUser: %w", dbErr)
svcErr := fmt.Errorf("svc.GetUser: %w", repoErr)
errors.Is(svcErr, dbErr) // true — traverses the chain
The if err != nil Pattern
The idiomatic Go way to handle errors — check immediately after every call that can fail.
func processFile(path string) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("processFile: %w", err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return fmt.Errorf("processFile read: %w", err)
}
if err := process(data); err != nil {
return fmt.Errorf("processFile process: %w", err)
}
return nil
}
Sentinel Errors — Package-Level Variables
Exported package-level error variables that callers can compare against. By convention, named ErrXxx.
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrTimeout = errors.New("operation timed out")
)
// Usage
switch {
case errors.Is(err, ErrNotFound):
http.Error(w, "not found", 404)
case errors.Is(err, ErrUnauthorized):
http.Error(w, "unauthorized", 401)
default:
http.Error(w, "internal error", 500)
}
panic — Unrecoverable Errors
panic stops normal execution, runs all deferred functions, then crashes the program. Use only for programming bugs and impossible states — not for expected failures like bad user input or missing files.
// Good use: assertion that should never fail
func mustPositive(n int) int {
if n <= 0 {
panic(fmt.Sprintf("mustPositive: got %d, want > 0", n))
}
return n
}
// Good use: package init failure
func init() {
db, err := sql.Open("postgres", dsn)
if err != nil {
panic("cannot connect to database: " + err.Error())
}
}
recover — Catching Panics
recover stops a panic and returns the panic value. Must be called directly inside a defer function. Commonly used in web servers to prevent a single request from crashing the whole server.
func safeRun(fn func()) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered panic: %v", r)
}
}()
fn()
return nil
}
err := safeRun(func() {
panic("something went wrong")
})
fmt.Println(err) // recovered panic: something went wrong
// Middleware pattern — protect HTTP handlers from panics
func recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("panic: %v", err)
http.Error(w, "internal server error", 500)
}
}()
next.ServeHTTP(w, r)
})
}
When to Use What
| Situation | Tool |
|---|---|
| Expected failure (validation, DB miss, network) | Return error |
| Add context as error bubbles up | fmt.Errorf("context: %w", err) |
| Check for specific error | errors.Is(err, ErrXxx) |
| Extract typed error | errors.As(err, &target) |
| Fatal startup failure (bad config, missing DB) | log.Fatal(err) — exits with code 1 |
| True programming bug / impossible state | panic |
| Library code — never crash the caller | Return error, never panic |
| Web server — isolate request panics | recover in middleware |
Gotchas
| Gotcha | Detail |
|---|---|
| Ignoring errors | result, _ := fn() silently swallows failures — always handle or propagate |
Not wrapping with %w | Wrapping adds context AND preserves the chain; without %w, errors.Is won't find the original |
panic for expected failures | Panics from user errors (404, bad input) will crash the server — use error |
recover outside defer | recover() called outside a deferred function always returns nil — doesn't catch panics |
Comparing wrapped errors with == | Use errors.Is not == when errors may be wrapped |