Why Go Errors Look Weird to Beginners#

If you’re coming from Python, JavaScript, or Java, Go’s error handling looks repetitive:

1
2
3
4
5
6
7
8
9
result, err := doSomething()
if err != nil {
    return err
}

data, err := doSomethingElse(result)
if err != nil {
    return err
}

In other languages, exceptions handle this automatically — an error thrown anywhere in the call stack bubbles up to the nearest catch. Go doesn’t have exceptions. Errors are just values returned from functions. You check them explicitly every time.

This isn’t an accident. It’s a deliberate design choice: errors should be visible, not hidden behind a language mechanism. The repetition is the point — it forces you to think about failure at every step.

The error Interface#

In Go, error is a built-in interface with one method:

1
2
3
type error interface {
    Error() string
}

Any type that has an Error() string method is an error. This means you can create custom error types easily, and any function can return error — the standard return type for all errors in Go.

The simplest way to create errors:

1
2
3
import "errors"

err := errors.New("connection refused")

For errors that include runtime data, use fmt.Errorf:

1
err := fmt.Errorf("trade ID %d not found in symbol %s", tradeID, symbol)

Wrapping Errors: Adding Context#

When an error travels up the call stack, each layer should add context about what it was doing. This makes the final error message much easier to debug:

1
2
3
4
5
6
7
// Without wrapping — not helpful
return err
// → "connection refused"

// With wrapping — tells you the full chain
return fmt.Errorf("fetching BTCUSDT trades from Binance: %w", err)
// → "fetching BTCUSDT trades from Binance: connection refused"

The %w verb (not %v) wraps the error so it can be unwrapped later. The convention: add context at each level describing what your function was trying to do.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func FetchTrades(symbol string, fromID int64) ([]Trade, error) {
    resp, err := http.Get(buildURL(symbol, fromID))
    if err != nil {
        return nil, fmt.Errorf("fetching trades for %s from ID %d: %w", symbol, fromID, err)
    }
    // ...
}

func RecoverGap(gap GapTask) error {
    trades, err := FetchTrades(gap.Symbol, gap.FromID)
    if err != nil {
        return fmt.Errorf("recovering gap %d-%d: %w", gap.FromID, gap.ToID, err)
    }
    // ...
}

The final error message: "recovering gap 1000-1050: fetching trades for BTCUSDT from ID 1000: connection refused". Each layer’s context is visible.

Checking Specific Error Types#

Sometimes you need to handle specific errors differently — retry on network errors, skip on not-found, abort on permission errors.

errors.Is: Comparing Error Values#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
var ErrNotFound = errors.New("not found")

func GetTrade(id int64) (*Trade, error) {
    if id > maxKnownID {
        return nil, ErrNotFound
    }
    // ...
}

// Caller
trade, err := GetTrade(99999)
if errors.Is(err, ErrNotFound) {
    // handle "not found" specifically
    return nil, nil  // treat as empty result, not an error
}
if err != nil {
    return nil, fmt.Errorf("getting trade: %w", err)
}

errors.Is checks the entire error chain — if ErrNotFound was wrapped five levels deep with %w, errors.Is still finds it.

errors.As: Extracting Custom Error Types#

When you need the error’s data, not just its identity:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
type RateLimitError struct {
    RetryAfter time.Duration
}

func (e *RateLimitError) Error() string {
    return fmt.Sprintf("rate limited, retry after %s", e.RetryAfter)
}

// Caller
_, err := FetchFromBinance(symbol)
var rateLimitErr *RateLimitError
if errors.As(err, &rateLimitErr) {
    time.Sleep(rateLimitErr.RetryAfter)
    // retry
}

errors.As unwraps the chain looking for a matching type and fills the pointer if found.

The Retry Pattern#

For network operations — REST API calls, database inserts, WebSocket reconnects — you want automatic retry with backoff:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func WithRetry(attempts int, fn func() error) error {
    var err error
    for i := 0; i < attempts; i++ {
        err = fn()
        if err == nil {
            return nil
        }
        // Don't retry if it's a non-recoverable error
        if errors.Is(err, ErrInvalidRequest) {
            return err
        }
        // Exponential backoff: 1s, 2s, 4s, ...
        time.Sleep(time.Duration(1<<i) * time.Second)
    }
    return fmt.Errorf("after %d attempts: %w", attempts, err)
}

// Usage
err := WithRetry(3, func() error {
    return clickhouse.Insert(ctx, batch)
})

The key: check for non-recoverable errors before sleeping. Retrying a 400 Bad Request is pointless.

Error Handling in Goroutines#

Errors from goroutines need a channel — you can’t return them directly:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
type result struct {
    trades []Trade
    err    error
}

func FetchParallel(symbols []string) (map[string][]Trade, error) {
    ch := make(chan result, len(symbols))

    for _, sym := range symbols {
        sym := sym  // capture loop variable
        go func() {
            trades, err := FetchTrades(sym, 0)
            ch <- result{trades, err}
        }()
    }

    all := make(map[string][]Trade)
    for range symbols {
        r := <-ch
        if r.err != nil {
            return nil, fmt.Errorf("parallel fetch: %w", r.err)
        }
        all[r.sym] = r.trades
    }
    return all, nil
}

For more complex cases, errgroup from golang.org/x/sync wraps this pattern cleanly — it cancels all goroutines when the first error occurs.

What Not to Do#

Don’t discard errors:

1
2
// Bad — silently ignoring the error
data, _ := os.ReadFile("config.toml")

Don’t wrap without %w:

1
2
// Bad — wraps but loses unwrappability
return fmt.Errorf("something failed: %v", err)  // use %w, not %v

Don’t add useless context:

1
2
3
4
5
// Bad — "error occurred" adds nothing
return fmt.Errorf("error occurred: %w", err)

// Good — describes what was being attempted
return fmt.Errorf("loading config from %s: %w", path, err)

Don’t use panic for expected errors:

1
2
3
4
5
// Bad — panic is for unrecoverable programmer errors, not operational failures
panic("database connection failed")

// Good
return fmt.Errorf("connecting to ClickHouse: %w", err)

The Pattern That Scales#

In a large pipeline with many layers (WebSocket handler → Redis flush worker → ClickHouse batch insert), consistent error wrapping means any failure message tells you the full chain:

"backfill pipeline: processing sync task 42: fetching trades for BTCUSDT from ID 1000-2000: REST request: rate limited, retry after 30s"

You can read this error and immediately know: which pipeline stage failed, which task it was processing, what symbol and ID range, what the root cause was, and how long to wait. No log digging, no debugger.

That’s the goal of Go’s error handling: make failures self-describing.