Go’s error handling is verbose by design. The verbosity is the point — explicit failure paths. The patterns that make it work in production are well-established. This post is the working set.

The basics

func loadConfig(path string) (*Config, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, fmt.Errorf("open %s: %w", path, err)
    }
    defer f.Close()
    
    var cfg Config
    if err := json.NewDecoder(f).Decode(&cfg); err != nil {
        return nil, fmt.Errorf("decode config: %w", err)
    }
    return &cfg, nil
}

%w wraps. Caller can unwrap and inspect. Without %w, the chain is broken.

Sentinels

var (
    ErrNotFound = errors.New("not found")
    ErrConflict = errors.New("conflict")
)

func GetUser(id int64) (*User, error) {
    if !exists(id) {
        return nil, ErrNotFound
    }
    ...
}

// Caller
user, err := GetUser(id)
if errors.Is(err, ErrNotFound) {
    // 404
}

Cheap, idiomatic. Works through wrapping (errors.Is traverses the chain).

Custom error types

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation: %s: %s", e.Field, e.Message)
}

// Caller
var ve *ValidationError
if errors.As(err, &ve) {
    log.Printf("invalid field: %s", ve.Field)
}

For errors that carry data. errors.As finds the wrapped instance.

Combining

A package can have both:

var ErrNotFound = errors.New("not found")

type DBError struct { Op string; Err error }
func (e *DBError) Error() string { return fmt.Sprintf("%s: %v", e.Op, e.Err) }
func (e *DBError) Unwrap() error { return e.Err }

func loadUser(id int64) (*User, error) {
    err := db.QueryRow(...).Scan(...)
    if err == sql.ErrNoRows {
        return nil, &DBError{Op: "loadUser", Err: ErrNotFound}
    }
    if err != nil {
        return nil, &DBError{Op: "loadUser", Err: err}
    }
    ...
}

// Caller still works:
if errors.Is(err, ErrNotFound) { ... }

Custom type with Unwrap enables errors.Is through the chain.

errors.Join

err := errors.Join(closeReader(), closeWriter(), syncFs())
if err != nil {
    return fmt.Errorf("cleanup: %w", err)
}

For multiple errors that all matter (cleanup, multi-step ops). errors.Is works on each.

fmt.Errorf vs errors.New

errors.New("simple message")             // package-level sentinel
fmt.Errorf("loading %s: %w", path, err)  // formatted + wrapped

Use errors.New for sentinels. fmt.Errorf("...: %w", err) for context + wrap.

When to wrap

Wrap when you add context that’s not in the original error:

// Good
return fmt.Errorf("loading user %d: %w", userID, err)

// Bad — adds nothing
return fmt.Errorf("error: %w", err)

The chain becomes a story: “loading user 42: querying users table: connection refused.”

When NOT to wrap

If the caller will only check sentinel/type, wrapping is fine. If the inner error is sensitive (e.g., reveals SQL), don’t wrap — replace with a generic public error and log the inner.

// Internal: log full
log.Errorf("internal: %v", innerErr)

// Public: generic
return fmt.Errorf("internal server error")

HTTP error mapping

type appError struct {
    Code    int
    Message string
}

func (e appError) Error() string { return e.Message }

func handleError(w http.ResponseWriter, err error) {
    var ae appError
    if errors.As(err, &ae) {
        http.Error(w, ae.Message, ae.Code)
        return
    }
    if errors.Is(err, ErrNotFound) {
        http.Error(w, "not found", 404)
        return
    }
    log.Printf("internal error: %v", err)
    http.Error(w, "internal", 500)
}

One function maps internal errors to HTTP responses. Handlers stay clean.

Repeated if err != nil { return err }

It IS verbose. The community has accepted that the verbosity is worth the explicitness. Tools that “fix” it (early-return generics, etc.) haven’t won.

What helps:

  • Wrap once at the boundary that adds value.
  • Structured logging so the error chain prints well.
  • Group steps that all fail together — sometimes a helper function reduces noise.

panic / recover

func init() {
    pool, err := sql.Open("postgres", dsn)
    if err != nil {
        log.Fatal(err)  // not panic — log.Fatal exits
    }
    DB = pool
}

For startup-only unrecoverable errors: log.Fatal is idiomatic.

In handlers:

defer func() {
    if r := recover(); r != nil {
        log.Printf("panic: %v\n%s", r, debug.Stack())
        http.Error(w, "internal", 500)
    }
}()

Recover at the boundary so one bad handler doesn’t kill the whole server. But — fix the panic; don’t normalize them.

Common mistakes

1. Swallowing errors

result, _ := someOp()  // BAD

Discards information. Always handle or log.

2. Comparing strings

if err.Error() == "not found" { ... }  // BAD

Brittle. Use sentinels with errors.Is.

3. Forgetting to wrap

return err  // chain broken; "what went wrong" lost

Add context: return fmt.Errorf("opX: %w", err).

4. Custom type without Unwrap

type MyErr struct { Inner error }
func (e *MyErr) Error() string { return e.Inner.Error() }
// MISSING Unwrap()

errors.Is won’t traverse. Always implement Unwrap when wrapping.

5. Panicking on recoverable errors

User input invalid → panic. Bad. Return error; let caller decide.

What I’d ship today

For a Go service:

  • Sentinels for known error types.
  • Custom types when carrying context.
  • Wrap with %w at boundaries.
  • errors.Is / errors.As at decision points.
  • One mapping function for HTTP / gRPC error codes.
  • Structured logging that prints the full error chain.
  • Defer + recover at request boundary.

Read this next

If you want my Go error handling reference (sentinels + types + HTTP mapping), it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .