golang · level 8

Errors

Explicit failure: error values, wrapping, errors.Is / errors.As.

125 XP

Errors in Go

Go has no exceptions for normal failures. A function that can fail returns an error as its last value, and the caller checks it. Verbose, yes — but every failure is visible at the call site.

The error interface

Built-in:

type error interface {
    Error() string
}

Anything with an Error() string method satisfies error. The most common way to create one:

import "errors"

err := errors.New("invalid input")
err2 := fmt.Errorf("could not fetch user %d: %w", id, originalErr)

fmt.Errorf lets you format with context. The %w verb wraps another error so callers can unwrap it later.

The if-err idiom

You'll see this pattern hundreds of times in any Go codebase:

data, err := os.ReadFile("config.json")
if err != nil {
    return fmt.Errorf("read config: %w", err)
}

var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
    return fmt.Errorf("parse config: %w", err)
}

The chain of if err != nil { return ... } is the cost of explicit failure handling. It does mean every failure path is local + visible.

Custom error types

Wrap context in a struct that implements error:

type NotFoundError struct {
    Resource string
    ID       int64
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s %d not found", e.Resource, e.ID)
}

func GetUser(id int64) (*User, error) {
    user := db.find(id)
    if user == nil {
        return nil, &NotFoundError{Resource: "user", ID: id}
    }
    return user, nil
}

The caller can check with errors.As to extract the typed value:

var nfe *NotFoundError
if errors.As(err, &nfe) {
    log.Printf("missing %s id=%d", nfe.Resource, nfe.ID)
}

Sentinel errors

For specific failure cases, define a package-level error variable:

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

func GetUser(id int64) (*User, error) {
    user := db.find(id)
    if user == nil {
        return nil, ErrNotFound
    }
    return user, nil
}

// Caller:
user, err := GetUser(id)
if errors.Is(err, ErrNotFound) {
    return nil, http.StatusNotFound
}

errors.Is walks the wrap chain — it works even after the error has been wrapped with fmt.Errorf("...: %w", err).

errors.Is vs errors.As

Helper Purpose
errors.Is(err, target) Is err (or anything it wraps) equal to target? Used with sentinel errors.
errors.As(err, &target) Is err (or anything it wraps) of the type of *target? If so, assign it. Used with custom error types.
// Sentinel — compare values.
if errors.Is(err, sql.ErrNoRows) { ... }

// Custom type — extract structured info.
var pgErr *pq.Error
if errors.As(err, &pgErr) {
    log.Printf("postgres %s", pgErr.Code)
}

panic vs error

panic is for programmer bugs and unrecoverable invariant violations, not normal failure. Reaching for panic in normal code paths is non-idiomatic Go.

// Normal failure: return an error.
if !user.IsActive {
    return errors.New("inactive user")
}

// Programmer bug: panic.
if mode != "read" && mode != "write" {
    panic("invalid mode: " + mode)
}

recover (called only inside a deferred function) catches a panic and lets you decide what to do. Most code never needs to recover — let the panic crash the program loudly so the bug gets noticed.

Don't ignore errors

// Bad — silent failure.
data, _ := os.ReadFile("config.json")

// Good — handle it.
data, err := os.ReadFile("config.json")
if err != nil {
    log.Fatal(err)
}

The blank identifier _ is fine when you genuinely don't care (e.g., defer f.Close() where the file is read-only). It's a footgun anywhere else.