# Go Error Handling

> Guides Go error handling as values — wrap with fmt.Errorf and %w to preserve the chain, inspect with errors.Is (sentinel) and errors.As / errors.AsType (typed) instead of string-matching, choose sentinel vs custom error types and when they become API, combine with errors.Join, write lowercase un-punctuated messages, handle each error exactly once, and decorate or close-with-error via named returns and defer. Auto-invokes when writing or editing error returns, fmt.Errorf, errors.Is/As, custom error types, or on "handle this error" / "why is this error not matching" requests. The depth behind the policy root's "errors are values, never silently discarded."

- Skill: `ctoth/go-error-handling` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-error-handling`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-error-handling/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: ctoth (https://skillmd.com/u/ctoth)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ctoth/go-error-handling

---


# Go Error Handling

> "The key lesson, however, is that errors are values and the full power of the Go programming language is available for processing them."
> — [Errors are values (Rob Pike)](https://go.dev/blog/errors-are-values)

> "In Go 1.13, the `fmt.Errorf` function supports a new `%w` verb ... wrapping an error makes that error part of your API. If you don't want to commit to supporting that error as part of your API in the future, you shouldn't wrap the error."
> — [Working with Errors in Go 1.13](https://go.dev/blog/go1.13-errors)

An `error` in Go is an ordinary value, not a thrown exception. That single fact decides everything below: you wrap values, you inspect values, you compare values — you never re-parse a rendered string. This skill owns the depth behind `go-idiomatic-discipline`'s headline floor ("errors are values, never silently discarded"); the floor — *always* check the error — is assumed here.

---

## 1. Wrap With `%w` to Preserve the Chain

When you add context as an error travels up the stack, use `fmt.Errorf` with `%w`. It returns an error whose `Unwrap` method returns the wrapped error, so the whole chain stays inspectable: "the error returned by `fmt.Errorf` will have an `Unwrap` method returning the argument of `%w` ... In all other ways, `%w` is identical to `%v`" ([Go 1.13 errors](https://go.dev/blog/go1.13-errors)).

```go
// WRONG — %v flattens err to a string; errors.Is/As downstream can never match
return fmt.Errorf("decompress %s: %v", name, err)

// RIGHT — %w keeps err in the chain for errors.Is / errors.As
return fmt.Errorf("decompress %s: %w", name, err)
```

Place `%w` at the end of the message: "Prefer to place `%w` at the end of an error string" ([Google Style Guide — best-practices](https://google.github.io/styleguide/go/best-practices#error-handling)). Each wrap "adds a new entry to the front of the error chain."

---

## 2. Inspect With `errors.Is` and `errors.As` — Never String-Match

`errors.Is` "reports whether any error in err's tree matches target"; `errors.As` "finds the first error in err's tree that matches target, and ... sets target to that error value" ([pkg.go.dev/errors](https://pkg.go.dev/errors)). Both walk the `%w` chain, so a deeply wrapped sentinel still matches. "In the simplest case, the `errors.Is` function behaves like a comparison to a sentinel error, and the `errors.As` function behaves like a type assertion. When operating on wrapped errors, however, these functions consider all the errors in a chain" ([Go 1.13 errors](https://go.dev/blog/go1.13-errors)).

```go
// WRONG — string match: brittle, breaks the moment the message is wrapped or reworded
if err.Error() == "not found" { ... }

// RIGHT — match the value through the whole chain
if errors.Is(err, ErrNotFound) { ... }

// RIGHT — extract a typed error and read its fields
var ve *ValidationError
if errors.As(err, &ve) {
	log.Printf("bad field: %s", ve.Field)
}
```

`errors.As` "panics if target is not a non-nil pointer to either a type that implements error, or to any interface type" ([pkg.go.dev/errors](https://pkg.go.dev/errors)) — the target is always `&someErr`, never `someErr`.

**Go 1.26:** `errors.AsType[E error](err error) (E, bool)` is "a generic version of `As`. It is type-safe, faster, and, in most cases, easier to use" ([Go 1.26 release notes](https://go.dev/doc/go1.26)). Prefer it when your module is on 1.26+:

```go
if pathErr, ok := errors.AsType[*fs.PathError](err); ok {
	log.Printf("failed at path: %s", pathErr.Path)
}
```

---

## 3. Sentinel Errors vs Custom Types

A **sentinel** is a fixed value matched with `errors.Is`: `var ErrNotFound = errors.New("not found")`. A **custom type** carries structured data and is matched with `errors.As`. Choose by what the caller needs: "For an error with a dynamic string, use `fmt.Errorf` if the caller does not need to match it, and a custom `error` if the caller does need to match it" ([Uber Go Style Guide](https://github.com/uber-go/guide/blob/master/style.md#errors)).

```go
// Sentinel — caller only needs to know "which" error
var ErrNotFound = errors.New("not found")

// Custom type — caller needs data off the error
type ValidationError struct {
	Field string
	Msg   string
}

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

If the caller does not need to interrogate the error, give it no structure at all. If it does, "give the error value structure so that this can be done programmatically rather than having the caller perform string matching" ([Google Style Guide](https://google.github.io/styleguide/go/best-practices#error-handling)).

**Both become API.** "Note that if you export error variables or types from a package, they will become part of the public API of the package" ([Uber](https://github.com/uber-go/guide/blob/master/style.md#errors)). Export sentinels and types *sparingly* — only when callers genuinely branch on them — and document them, so callers can anticipate what they may handle.

---

## 4. The Wrap-vs-Don't-Wrap Decision

"Wrap an error to expose it to callers. Do not wrap an error when doing so would expose implementation details" ([Go 1.13 errors](https://go.dev/blog/go1.13-errors)). The canonical trap: a function that internally uses `database/sql` and lets `sql.ErrNoRows` leak through `%w` has now promised that error forever — "wrapping an error makes that error part of your API." Use `%v` at that boundary to deliberately *obfuscate* the cause:

```go
// RIGHT — %w: the wrapped error IS part of this function's contract
return fmt.Errorf("loading user %d: %w", id, ErrNotFound)

// RIGHT — %v: deliberately hide an internal type so it can't become API
return fmt.Errorf("accessing DB: %v", err) // caller cannot reach sql.ErrNoRows
```

Uber frames the same `%w`/`%v` fork: "Use `%w` if the caller should have access to the underlying error ... Use `%v` to obfuscate the underlying error. Callers will be unable to match it, but you can switch to `%w` in the future if needed" ([Uber](https://github.com/uber-go/guide/blob/master/style.md#errors)). `%w` is the good default; `%v` is the conscious choice to keep an option open.

---

## 5. `errors.Join` for Multiple Errors

When several independent operations can each fail (validating many fields, closing many resources), collect them. `errors.Join` (Go 1.20) "returns an error that wraps the given errors. Any nil error values are discarded. Join returns nil if every value in errs is nil" ([pkg.go.dev/errors](https://pkg.go.dev/errors)). The result implements `Unwrap() []error`, and `errors.Is`/`As` traverse every branch.

```go
var errs []error
for _, f := range fields {
	if err := validate(f); err != nil {
		errs = append(errs, err)
	}
}
return errors.Join(errs...) // nil if all validations passed
```

---

## 6. Message Conventions

"Error strings should not be capitalized (unless beginning with proper nouns or acronyms) or end with punctuation, since they are usually printed following other context" ([CodeReviewComments — Error Strings](https://go.dev/wiki/CodeReviewComments#error-strings)). And keep context succinct: drop "failed to", which "state[s] the obvious and pile[s] up as the error percolates up through the stack" ([Uber](https://github.com/uber-go/guide/blob/master/style.md#errors)).

```go
// WRONG — capitalized, punctuated, and a "failed to" chain that stacks badly
//   failed to x: failed to y: Failed to create new store.
return fmt.Errorf("Failed to create new store: %w", err)

// RIGHT — lowercase, no period, terse; chains read as "x: y: new store: <cause>"
return fmt.Errorf("new store: %w", err)
```

Name sentinels with an `Err`/`err` prefix and custom types with an `Error` suffix ([Uber](https://github.com/uber-go/guide/blob/master/style.md#errors)) — `go-naming-and-style` owns the full naming rule.

---

## 7. Handle Each Error Once

An error is handled once: match it, recover from it, return it (wrapped or bare) — but not two of those. The most common double-handling is **log-and-return**: "If you return an error, it's usually better not to log it yourself but rather let the caller handle it" ([Google Style Guide](https://google.github.io/styleguide/go/best-practices#error-handling)); doing both "makes a lot of noise in the application logs for little value" ([Uber](https://github.com/uber-go/guide/blob/master/style.md#errors)).

```go
// WRONG — logged here AND returned: every layer logs the same failure
u, err := getUser(id)
if err != nil {
	log.Printf("could not get user %q: %v", id, err)
	return err
}

// RIGHT — wrap and return; let the caller decide whether to log
u, err := getUser(id)
if err != nil {
	return fmt.Errorf("get user %q: %w", id, err)
}
```

Logging *and degrading gracefully* (not returning) is fine; that is handling it once. The log side of this rule is owned by `go-slog-logging`.

---

## 8. Line of Sight and Defer-Based Decoration

Handle the error first and return, keeping the happy path flat: "keep the normal code path at a minimal indentation, and indent the error handling, dealing with it first" ([CodeReviewComments — Indent Error Flow](https://go.dev/wiki/CodeReviewComments#indent-error-flow)). To add context on *every* return path, or to surface a `Close` error, use a **named return** plus `defer`:

```go
func writeFile(path string, data []byte) (err error) {
	f, err := os.Create(path)
	if err != nil {
		return fmt.Errorf("creating %s: %w", path, err)
	}
	defer func() {
		// surface the Close error only if the body didn't already fail
		if cerr := f.Close(); cerr != nil && err == nil {
			err = fmt.Errorf("closing %s: %w", path, cerr)
		}
	}()

	if _, err := f.Write(data); err != nil {
		return fmt.Errorf("writing %s: %w", path, err)
	}
	return nil
}
```

A bare `defer f.Close()` silently drops a flush/close error on a file you *wrote* — for writers, capture it. This skill owns the error-decoration *idiom*; the `defer` mechanics it relies on (LIFO, argument evaluation timing, loop-defer) belong to `go-defer-panic-recover`.

---

## 9. Don't `panic` for Ordinary Errors

A failed lookup, bad input, or missing file is a value to return, not a process to crash. "Don't use panic for normal error handling. Use error and multiple return values" — routed in full to `go-defer-panic-recover`. The typed-nil trap (returning `*MyError`-typed nil as an `error`, which is never `nil`) is an interface-representation fact owned by `go-interfaces`; declare returns as `error`, not `*MyError`.

---

## 10. Who Suffers When Errors Are Mishandled

A mishandled error never hurts the author at write time — it hurts someone later:

- The **on-call engineer** staring at a `500` with a log line that reads only `error: EOF` — because the error was returned bare, with no `%w` context naming *which* call to *which* dependency failed. The wrap they didn't write is the breadcrumb trail they don't have at 3am.
- The **teammate** who loses an afternoon to a bug whose root cause was a discarded `_ = json.Unmarshal(...)` three layers down: the malformed payload became a silent zero-value struct, and the failure surfaced somewhere unrelated. "Whatever you do, always check your errors!" ([Errors are values](https://go.dev/blog/errors-are-values)) is written for that teammate.
- The **next maintainer** who can't tell whether `if err == ErrNotFound` is safe, because somewhere upstream a caller wrapped that sentinel with `%w` and the bare `==` now silently never matches — the bug §2 exists to prevent.

Good error handling is empathy expressed in code: the context you add, the sentinel you expose with `errors.Is`, and the error you *don't* swallow are all gifts to whoever debugs this under load.

---

## 11. Routing to Related Skills

- `go-idiomatic-discipline` — the policy root; this skill is the depth behind its "errors are values" floor.
- `go-defer-panic-recover` — `defer` mechanics behind §8, and when `panic`/`recover` are legitimate.
- `go-interfaces` — the typed-nil error gotcha; why a function returns `error`, not a concrete pointer type.
- `go-naming-and-style` — the `Err` prefix / `Error` suffix naming rule and error-string casing.
- `go-slog-logging` — the log side of "handle once" (§7).
- `go-tooling-and-static-analysis` — `errorlint` (catches `%v`-instead-of-`%w` and `==` comparisons) and `errcheck` (catches discarded errors) in CI.

---

## 12. Reference Files

High-frequency error-handling anti-patterns in LLM-generated Go, each with wrong/right code and citations:

[references/common-mistakes.md](references/common-mistakes.md)

Source provenance for every claim in this skill:

[references/sources.yaml](references/sources.yaml)

