Error Handling

Difficulty

This is one of Go's most debated design choices, and understanding the reasoning behind it matters more than memorizing the syntax.

The basic shape

type error interface {
    Error() string
}

func ReadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, err
    }
    return &cfg, nil
}

Every function that can fail returns error as its last value, by convention, and callers are expected to check it before touching any other returned value.

Why not exceptions

// Java: a failure can come from anywhere in the call stack, invisibly
try {
    doSomething();  // might throw, but the signature doesn't necessarily say so (unchecked exceptions)
} catch (SomeException e) {
    // ...
}

An unchecked exception can propagate through many stack frames of code that never declared or expected it, only to be caught (or not) far away from where it happened. Go's designers considered this a source of surprising control flow, and chose to make every failure path an explicit, visible value instead.

The real tradeoff

data, err := step1()
if err != nil {
    return err
}
result, err := step2(data)
if err != nil {
    return err
}
final, err := step3(result)
if err != nil {
    return err
}

This is noticeably more verbose than a single try block wrapping several calls. Go accepts that verbosity as the price of never hiding a failure path. Idioms like early returns and small helper functions exist specifically to keep this pattern readable.

The interview framing

Go treats errors as values, not control-flow events. That single decision explains almost every other error-handling idiom in the language, including wrapping, errors.Is/errors.As, and why panic is reserved for truly exceptional situations rather than ordinary failure handling.

Related Resources

Wrapping solves a real, common problem: a raw error message from deep inside your code often doesn't tell you where it happened, only what happened.

Without wrapping

func loadUser(id int) (*User, error) {
    data, err := db.Query(id)
    if err != nil {
        return nil, err  // caller just sees the raw database error
    }
    // ...
}

If this fails, the caller sees something like connection refused, with no indication it happened while loading a user.

With %w wrapping

func loadUser(id int) (*User, error) {
    data, err := db.Query(id)
    if err != nil {
        return nil, fmt.Errorf("loading user %d: %w", id, err)
    }
    // ...
}
Error: loading user 42: connection refused

Now the message tells you both what happened and where, while the original connection refused error is still reachable underneath.

Why %w specifically, not %v

err1 := fmt.Errorf("context: %w", original)  // original still reachable via errors.Unwrap
err2 := fmt.Errorf("context: %v", original)  // original is gone, just formatted into a new string

%w generates an Unwrap() error method on the returned error automatically, forming a chain. %v just stringifies the error into the message text, losing the ability to programmatically check against the original error later.

Checking through a wrap chain

err := loadUser(42)
if errors.Is(err, sql.ErrNoRows) {
    fmt.Println("user not found")
}

errors.Is walks the entire wrap chain, checking each layer, so this still works correctly even though loadUser added its own context on top of the original sql.ErrNoRows.

These two functions solve related but distinct problems: "is it this exact error" versus "is it this kind of error, and what data does it carry."

errors.Is: checking identity through a wrap chain

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

func findUser(id int) error {
    return fmt.Errorf("finding user %d: %w", id, ErrNotFound)
}

err := findUser(42)
if errors.Is(err, ErrNotFound) {
    fmt.Println("no such user")   // true, even though err's message is different text
}

errors.Is walks the chain (err → unwrap → unwrap → ...) checking each layer against ErrNotFound, so wrapping doesn't break the check.

errors.As: extracting a specific error type's data

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

func validate(input string) error {
    if input == "" {
        return fmt.Errorf("checking input: %w", &ValidationError{Field: "name", Msg: "cannot be empty"})
    }
    return nil
}

err := validate("")
var valErr *ValidationError
if errors.As(err, &valErr) {
    fmt.Println("field:", valErr.Field)   // "name" — pulled out the concrete type's data
}

errors.As searches the chain for the first error matching *ValidationError, and assigns it into valErr so you can read its specific fields, something errors.Is can't do since it only checks equality.

Choosing between them

Question you're askingUse
"Is this the specific io.EOF / sql.ErrNoRows / my sentinel error?"errors.Is
"Is this a *ValidationError (or similar custom type), and what data does it hold?"errors.As

A good rule: reach for errors.Is with sentinel error values, and errors.As with custom error types that carry extra data you need to read.

Related Resources

Sentinel errors are the simplest error pattern in Go, and they're everywhere in the standard library, but they have real limits.

A typical sentinel

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

func FindUser(id int) (*User, error) {
    user, ok := db[id]
    if !ok {
        return nil, ErrNotFound
    }
    return user, nil
}

_, err := FindUser(42)
if errors.Is(err, ErrNotFound) {
    fmt.Println("no such user")
}

The drawback: no extra context

if errors.Is(err, ErrNotFound) {
    // You know SOMETHING wasn't found.
    // You do NOT know which ID, which table, or any other detail,
    // unless the caller happened to already have that information separately.
}

Compare this to a custom error type, which can carry the missing ID directly:

type NotFoundError struct{ ID int }
func (e *NotFoundError) Error() string { return fmt.Sprintf("id %d not found", e.ID) }

The drawback: an implicit public API contract

// package db
var ErrConnClosed = errors.New("connection closed")

Once external code does errors.Is(err, db.ErrConnClosed), that sentinel is effectively part of db's public API, exactly like an exported function. Renaming it, removing it, or changing when it's returned becomes a breaking change for every caller relying on it, even though it's "just a variable."

When sentinels are still the right choice

For a small, stable set of well-known conditions (think io.EOF, which will never need extra fields and will never change), a sentinel is simpler and perfectly idiomatic. Reach for a custom error type instead when callers need structured data out of the failure, not just a yes/no identity check.

The line between "return an error" and "panic" comes down to one question: could a correct, well-behaved caller reasonably trigger this?

Error: expected, recoverable conditions

func OpenConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("reading config: %w", err)
    }
    // ...
}

A missing config file is completely plausible in normal operation — a user typo'd a path, a deploy didn't include the file yet. The caller should get a chance to log it, retry, or fall back to defaults.

Panic: a violated invariant, a real bug

func MustCompile(pattern string) *regexp.Regexp {
    re, err := regexp.Compile(pattern)
    if err != nil {
        panic(err)   // a hardcoded, "known good" pattern that fails to compile is a bug
    }
    return re
}

The standard library's own regexp.MustCompile, template.Must, and similar Must* functions follow this exact pattern: they're meant to be called with values that are hardcoded and known correct at compile time, so a failure indicates a programmer mistake, not a runtime condition a caller should handle.

The Must-prefix convention

var validEmailPattern = regexp.MustCompile(`^[^@]+@[^@]+$`)  // panics at package init if the pattern is wrong — good, catch it immediately

Using Must* at package-level initialization is a common, accepted pattern: if the hardcoded input is wrong, you want the program to fail immediately and loudly at startup, not limp along with a broken regex.

A simple decision table

SituationChoice
A file might not existerror
A network call might failerror
User input might be invaliderror
An index you computed yourself goes out of boundsBug — let the runtime panic surface it
A required config value that should always be set by the time this code runs is nilpanic, deliberately, to fail fast

Related Resources