Go Interview Prep #7: Error Handling

Go’s error handling is explicit by design: errors are values, returned like any other value, checked by the caller. It is one of the most frequently discussed topics in Go interviews because it differs so sharply from exception-based languages.

The error interface

error is a built-in interface with a single method:

type error interface {
    Error() string
}

Any type that implements Error() string is an error. Treat error like any other interface — including the nil interface trap when returning typed nil pointers.

Creating errors

// Simple string error
err := errors.New("connection refused")

// Formatted error (no wrap)
err := fmt.Errorf("user %d not found", id)

// Wrapped error (preserves the chain)
err := fmt.Errorf("load config: %w", originalErr)

The %w verb wraps an error so callers can unwrap and inspect it with errors.Is / errors.As. Use a short operation prefix ("load config: %w") so logs show the call path.

Custom error types

Define a struct when you need to carry extra data:

type NotFoundError struct {
    Resource string
    ID       int
}

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

Use pointer receivers for Error() when the type is large or mutable; either works for small structs. Prefer errors.As over type assertions so wrapping still works.

Optional: implement Is / Unwrap on custom types when you need custom matching or multi-error chains.

errors.Is and errors.As

errors.Is checks identity — whether any error in the chain matches a specific sentinel value (via == or an Is method):

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

err := fmt.Errorf("query: %w", ErrNotFound)
errors.Is(err, ErrNotFound) // true

errors.As checks type — whether any error in the chain is assignable to a specific type, and sets the target:

var nfe *NotFoundError
if errors.As(err, &nfe) {
    fmt.Println("missing:", nfe.Resource)
}

Both traverse the full error chain built with %w (and Unwrap()). Prefer them over err == ErrX or type switches on the top-level error alone.

Sentinel errors

Sentinel errors are package-level var errors used for comparison. They are simple but have a drawback: you need wrapping (%w) to add context while keeping them matchable. Use them for well-known, stable conditions like io.EOF and sql.ErrNoRows.

var ErrTimeout = errors.New("timeout")

Do not compare error strings (err.Error() == "...") — brittle and i18n-hostile. Use Is/As.

Multiple errors: errors.Join

Since Go 1.20, errors.Join combines several errors into one. errors.Is / errors.As inspect all joined errors:

err := errors.Join(errA, errB)
errors.Is(err, errA) // true

Useful when validating many fields or shutting down multiple resources. Joining with only nil errors returns nil.

panic and recover

panic unwinds the stack and terminates the program unless caught by recover in a deferred function. It is not error handling — use it only for truly unrecoverable states (programming errors, invariant violations).

defer func() {
    if r := recover(); r != nil {
        log.Println("recovered:", r)
        // often: convert to error, or re-panic after cleanup
    }
}()
panic("something impossible happened")

recover only works in the same goroutine that panicked. A panic in one goroutine does not get caught by recover in another. HTTP servers often recover per-request to avoid taking down the process; library code should almost always return error instead.

Never use panic/recover as a substitute for returning errors in normal control flow.

Common interview questions

Q: Why doesn’t Go use exceptions?
Explicit error returns make it clear which functions can fail and force the caller to decide what to do. Exceptions encourage ignoring failures and make control flow harder to follow. (Go still has panic for truly exceptional bugs.)

Q: What is the difference between errors.Is and errors.As?
errors.Is matches a specific value (identity / sentinel). errors.As matches a specific type (and populates the target). Use Is for sentinel errors; use As for typed errors carrying data.

Q: When should you wrap an error vs create a new one?
Wrap (%w) when the caller may need to inspect or match the original error. Create a new one (errors.New or fmt.Errorf without %w) when the original is an implementation detail the caller should not depend on. At process boundaries (e.g. API handlers), often map to stable error codes and log the full chain.

Q: Is it okay to ignore an error?
Rarely. Use the blank identifier _ explicitly if you intentionally ignore one, and leave a comment explaining why. Common legitimate cases: best-effort cleanup in defer (or use errors.Join with the primary error), or closing a response body after a failed read when you only care about the first error.

Q: What is the nil interface trap for errors?
Returning a nil pointer of a concrete error type stored in an error interface yields a non-nil interface value. Always return nil (untyped) on success, not a typed nil.

Quick reference

ToolPurpose
errors.NewSimple error / sentinel
fmt.Errorf("%w", err)Wrap with context
errors.Is(err, target)Match sentinel in chain
errors.As(err, &target)Match type in chain
errors.Join(errs...)Combine multiple errors (Go 1.20+)
Custom struct + Error()Carry extra data
panic / recoverUnrecoverable bugs only; recover only in defer