# Go Errors

> Enforces Go error handling — errors as values, wrapping with %w, errors.Is and errors.As over type assertions, sentinel and custom error types, and when panic is acceptable. Use when writing, reviewing, or debugging Go error paths, and when the user mentions err != nil, error wrapping, errors.Is, errors.As, sentinel errors, panic, recover, errors.Join, or asks "how should I return this error", "why is errors.Is failing", "should this panic".

- Skill: `caslubbers/go-errors` (Agent Skill)
- Install (CLI): `npx skillmds@latest add caslubbers/go-errors`
- Raw SKILL.md: https://api.skillmd.com/api/skills/caslubbers/go-errors/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: CasLubbers (https://skillmd.com/u/caslubbers)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/caslubbers/go-errors

---


# Go errors

Errors are ordinary values. There is no exception mechanism to fall back on, so the error path is designed, not discovered.

## Wrap with context, keep the chain

Every layer adds what it was doing. `%w` preserves the original for inspection.

```go
// Good — reads as a path once printed: "load user: query user 42: sql: no rows in result set"
if err := rows.Scan(&u.ID, &u.Name); err != nil {
    return nil, fmt.Errorf("query user %s: %w", id, err)
}

// Bad — chain broken, errors.Is downstream stops working
return nil, fmt.Errorf("query user: %v", err)

// Bad — no context, caller cannot tell which of six calls failed
return nil, err
```

Wrap when you add information. Returning `err` unchanged is correct when the callee's message already says everything.

## Error strings are lowercase and unpunctuated

They get embedded in other messages.

```go
errors.New("connection refused")     // good
errors.New("Connection refused.")    // bad — reads wrong once wrapped
```

Do not start with the word "error" or "failed to" — the context makes that obvious.

## Inspect with errors.Is and errors.As

Never compare with `==` on a wrapped error, and never type-assert directly.

```go
// Sentinel — a known condition callers branch on
var ErrNotFound = errors.New("not found")

if errors.Is(err, ErrNotFound) {
    return http.StatusNotFound
}

// Typed — the caller needs data out of the error
type ValidationError struct {
    Field  string
    Reason string
}

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

var ve *ValidationError
if errors.As(err, &ve) {
    log.Printf("field %s rejected", ve.Field)
}
```

Choose a sentinel when callers only need to know *which* condition. Choose a type when they need details. Export either only if callers genuinely branch on it — an exported sentinel is part of your API forever.

## Unwrap for custom types

A custom error that carries a cause must expose it, or `errors.Is` cannot see through it.

```go
type QueryError struct {
    Query string
    Err   error
}

func (e *QueryError) Error() string { return e.Query + ": " + e.Err.Error() }
func (e *QueryError) Unwrap() error { return e.Err }
```

## Combining errors

`errors.Join` collects several failures where you must not stop at the first — validating a whole form, closing several resources.

```go
var errs error
for _, f := range fields {
    errs = errors.Join(errs, f.Validate())
}
return errs
```

`errors.Is` matches against any error in a joined set.

## Never discard silently

```go
// Bad
result, _ := doWork()

// Fine — the discard is deliberate and explained
_ = resp.Body.Close() // best effort; response already read
```

In `defer`, a failing `Close` on a *writer* can lose data. Capture it:

```go
defer func() {
    if cerr := f.Close(); cerr != nil && err == nil {
        err = fmt.Errorf("close: %w", cerr)
    }
}()
```

That requires a named return: `func write() (err error)`.

## panic is for programmer error only

Panic when the program cannot sensibly continue — an impossible switch branch, a failed invariant, a `MustCompile` at init with a literal pattern. Never panic across a package boundary for anything a caller could have caused: bad input, a missing file, a network failure. Those are returned errors.

A library that panics on user input is a broken library. If you must recover, do it at a process boundary (an HTTP middleware) and convert to an error there.

## Where to log

Log where you handle, not where you return. Logging *and* returning produces the same failure printed five times at five layers. The top of the call stack decides what the user sees.

