Go doesn’t have exceptions. Functions that can fail return an error as their last value, and the caller decides what to do. The whole story fits in one interface, one package, and a handful of patterns — but interviewers love to test whether you actually understand the idioms.

import "errors"
import "fmt"   // for fmt.Errorf wrapping

At a glance

Need to…Use
Create a simple errorerrors.New("message")
Format a message with valuesfmt.Errorf("...%v...", x)
Wrap another error with contextfmt.Errorf("...%w...", err)
Check if an error is a specific sentinelerrors.Is(err, ErrNotFound)
Extract a specific error typeerrors.As(err, &myErr)
Combine multiple errorserrors.Join(err1, err2)

The error type

error is just a built-in interface with one method:

type error interface {
    Error() string
}

Anything with an Error() string method satisfies it. The convention is to return errors as the last return value:

func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("divide by zero")
    }
    return a / b, nil
}

result, err := divide(10, 0)
if err != nil {
    fmt.Println("error:", err)
    return
}

if err != nil is the most-typed line of code in Go. It’s verbose. Get used to it.

Creating errors

errors.New — a constant message

err := errors.New("file not found")
fmt.Println(err)   // "file not found"

Use when the message doesn’t need any dynamic values.

fmt.Errorf — a formatted message

err := fmt.Errorf("file %q not found", filename)

Use when you want to include variables. Same format verbs as fmt.Printf.

Sentinel errors — named values you can compare against

A sentinel is a package-level error you export so callers can check for it:

package store

import "errors"

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

func Get(id string) (Item, error) {
    if !exists(id) {
        return Item{}, ErrNotFound
    }
    // ...
}

Callers compare:

item, err := store.Get("xyz")
if errors.Is(err, store.ErrNotFound) {
    // handle missing item
}

Don’t compare with == when there might be wrapping involved (see next section). Use errors.Is.

Familiar examples from the standard library: io.EOF, sql.ErrNoRows, os.ErrNotExist.

Wrapping errors with %w

When a function passes an error up, it usually wants to add context — what it was doing when the error happened. Wrapping preserves the original error while adding a layer.

func loadConfig(path string) error {
    data, err := os.ReadFile(path)
    if err != nil {
        return fmt.Errorf("loadConfig %q: %w", path, err)
    }
    // ...
}

The %w verb is special — it doesn’t just format the error as text, it wraps it so the caller can still recognize the underlying error.

Result when printed:

loadConfig "config.json": open config.json: no such file or directory

You see the wrapping chain in order: outer context → inner context → original error.

Use %w exactly once per fmt.Errorf call. Multiple %w works since Go 1.20 (the result wraps all of them), but stick to one unless you’re deliberately joining errors.

Checking errors — errors.Is and errors.As

These are the two functions that make wrapping useful.

errors.Is — “is this error (or anything it wraps) equal to X?”

err := loadConfig("missing.json")

if errors.Is(err, os.ErrNotExist) {
    fmt.Println("config file is missing")
}

Is walks the wrapping chain, comparing each layer to os.ErrNotExist. If any of them match, it returns true. This is what makes sentinels work even after wrapping.

errors.As — “is there an error of this type in the chain?”

When you have a custom error type with extra fields, As extracts it:

type ValidationError struct {
    Field string
    Msg   string
}

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

// ...

err := saveUser(u)

var vErr *ValidationError
if errors.As(err, &vErr) {
    fmt.Println("validation failed on field:", vErr.Field)
}

As walks the chain, finds the first error of matching type, and assigns it to the target pointer.

errors.Is is for sentinel values. errors.As is for custom types. That’s the one-line rule.

Custom error types

When an error needs to carry extra information (a status code, a field name, the input that failed), make a type.

type HTTPError struct {
    Code int
    Msg  string
}

func (e *HTTPError) Error() string {
    return fmt.Sprintf("HTTP %d: %s", e.Code, e.Msg)
}

// Use it:
return &HTTPError{Code: 404, Msg: "not found"}

// Caller extracts it:
var httpErr *HTTPError
if errors.As(err, &httpErr) {
    fmt.Println("status code:", httpErr.Code)
}

Define the method on the pointer (*HTTPError), not the value. Then return &HTTPError{...}. This is the standard pattern; it lets errors.As work cleanly and avoids accidental copies.

Optional: implement Unwrap for deeper wrapping

If your custom error itself wraps another, expose it:

type DatabaseError struct {
    Query string
    Err   error
}

func (e *DatabaseError) Error() string {
    return fmt.Sprintf("query %q failed: %v", e.Query, e.Err)
}

func (e *DatabaseError) Unwrap() error {
    return e.Err
}

Now errors.Is and errors.As can walk through your custom error into whatever it wraps.

Combining errors — errors.Join (Go 1.20+)

When several things go wrong and you want to report all of them:

var errs []error

for _, item := range items {
    if err := validate(item); err != nil {
        errs = append(errs, err)
    }
}

if len(errs) > 0 {
    return errors.Join(errs...)
}

The joined error’s message is each child error on its own line. errors.Is and errors.As still work — they check every joined error.

Common interview patterns

”What’s idiomatic Go error handling?”

The expected answer:

  1. Functions return (result, error) as the last value.
  2. Caller checks if err != nil { ... } and decides what to do.
  3. Wrap with fmt.Errorf("doing X: %w", err) to add context as the error travels up.
  4. At the top of the stack, check with errors.Is (for sentinels) or errors.As (for typed errors) to handle specific cases differently from generic ones.
  5. No exceptions, no try/catch, no hidden control flow.

”Why wrap errors instead of returning them as-is?”

Without wrapping, you lose context: "no such file or directory" doesn’t tell you which file. Wrapping adds the where/what/why as the error climbs the call stack. errors.Is/As still see through the wrapping, so you don’t trade context for the ability to compare.

Return early on error (the “happy path” pattern)

func process(path string) error {
    data, err := os.ReadFile(path)
    if err != nil {
        return fmt.Errorf("read %q: %w", path, err)
    }

    parsed, err := parse(data)
    if err != nil {
        return fmt.Errorf("parse %q: %w", path, err)
    }

    if err := validate(parsed); err != nil {
        return fmt.Errorf("validate %q: %w", path, err)
    }

    return save(parsed)
}

Each error is returned immediately and wrapped with the operation it was doing. The “happy path” stays at the left margin, errors short-circuit out. This is the pattern.

Convert a sentinel-or-fail check

func getUser(id string) (*User, error) {
    u, err := db.Find(id)
    switch {
    case errors.Is(err, sql.ErrNoRows):
        return nil, ErrUserNotFound          // map to your package's sentinel
    case err != nil:
        return nil, fmt.Errorf("db lookup: %w", err)
    }
    return u, nil
}

Don’t lose the original error

// ❌ Bad — original error is gone
return fmt.Errorf("something failed: %s", err.Error())

// ❌ Bad — same problem, just uses %v
return fmt.Errorf("something failed: %v", err)

// ✅ Good — wraps so errors.Is/As still work
return fmt.Errorf("something failed: %w", err)

%s and %v format the error as a string. %w preserves the chain.

Quick rules of thumb

  • Last return value is error. Always.
  • Always check if err != nil. Don’t ignore errors with _.
  • Add context with %w — never %v or %s for wrapping.
  • errors.Is for sentinels, errors.As for custom types.
  • Define custom error methods on the pointer (func (e *MyErr) Error() string).
  • Sentinels start with Err by convention: ErrNotFound, ErrTimeout.

Common mistakes

  • Ignoring errors with _ — silently swallows real problems. If you really don’t care, comment why.
  • Wrapping with %s or %v — flattens the chain; errors.Is/As stop working.
  • Comparing wrapped errors with == — fails when there’s wrapping. Use errors.Is.
  • errors.As(err, &SomeStruct{}) — must pass a pointer to a pointer (or pointer to interface). Pass &myErr where myErr is *SomeStruct, not SomeStruct{}.
  • Panicking instead of returning errorspanic is for programmer mistakes (nil deref, out-of-bounds), not for runtime conditions. Return an error.
  • Defining error variables without the Err prefix — readers expect ErrNotFound, not NotFoundError or notFound.
Toggle theme (T)