Why Your Go Error Wrapping Is Quietly Lying to You

Most Go developers think they handle errors correctly — until errors.Is returns false in production and nobody knows why. Golang error handling looks simple on the surface: return err, wrap it, check it. The surface is lying. The gap between “compiles and runs” and “actually preserves the error chain” is where junior and mid-level devs silently break things for months.

Understanding Go error handling internals — how the chain actually works, where it breaks, and why errors.Is returns false when you’re certain it shouldn’t — is what separates code that survives production from code that just compiles and ships.


TL;DR: Quick Takeaways

  • Using %v instead of %w in fmt.Errorf silently destroys your error chain — errors.Is will always return false downstream.
  • errors.Is matches sentinel values by identity through the chain; errors.As extracts typed structs — mixing them up causes bugs that look like logic errors.
  • Wrapping with %w is an API contract: if you expose sql.ErrNoRows through %w, callers can depend on it and you can’t change your DB layer without a breaking change.
  • Custom error types without an Unwrap() method silently break errors.Is and errors.As traversal — the chain stops at your type.

The %v Trap: How You Silently Break Your Error Chain

The single most common golang error wrapping mistake costs nothing at compile time and everything at runtime. A developer writes fmt.Errorf(“failed to fetch user: %v”, err) — looks fine, reads fine, logs fine. But %v formats the error as a string and throws away the original value. The new error has no memory of what it came from. Any sentinel you were checking for downstream — gone. Any typed struct you were trying to extract — unreachable. errors.Is will return false every single time, and you’ll spend an afternoon wondering why your switch statement never hits the right case.

// BROKEN — error chain severed
wrapped := fmt.Errorf("operation failed: %v", ErrNotFound)
fmt.Println(errors.Is(wrapped, ErrNotFound)) // false

// CORRECT — chain preserved
wrapped := fmt.Errorf("operation failed: %w", ErrNotFound)
fmt.Println(errors.Is(wrapped, ErrNotFound)) // true

The difference is one character. The behavioral difference is total. %w calls errors.Unwrap() under the hood, which lets the standard library traverse the chain. %v produces a dumb string with zero traversal support.

Verb Preserves error chain errors.Is works errors.As works
%v No No No
%w Yes Yes Yes

Why This Bug Survives Code Review

Because fmt.Errorf golang example code in most tutorials uses %v — it’s older, it’s everywhere, and it “works” if you never check errors.Is. Teams that log errors and move on never notice. The bug only surfaces when someone adds a retry policy or error-specific fallback months later and can’t understand why the sentinel matching fails.

errors.Is vs errors.As: What Junior Devs Get Backwards

These two functions look symmetrical but solve completely different problems. Golang errors.Is checks whether a specific error value appears anywhere in the chain — it’s identity comparison, not type comparison. errors.As walks the same chain looking for an error that can be assigned to a target type, then extracts it so you can access its fields. The classic mid-level mistake: using errors.Is on a custom struct type. It will never match unless your struct implements a custom Is() method, because errors.Is compares by value equality, and two struct instances are not equal by default in Go.

type ValidationError struct {
 Field string
 Message string
 Err  error
}

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

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

// errors.Is won't help you here — use errors.As
var valErr *ValidationError
if errors.As(err, &valErr) {
 fmt.Println(valErr.Field) // "email"
}

errors.As gives you the actual struct pointer with all its fields intact. Type assertion (err.(*ValidationError)) would work too — but only if the error is exactly that type at the top level. errors.As traverses the full chain. That’s the architectural difference worth remembering.

The Decision Is Simpler Than It Looks

If you’re checking “did this specific thing go wrong” and the sentinel is a package-level var — use errors.Is. If you need data out of the error — a status code, a field name, a retry interval — use errors.As. Using golang type assertion instead of errors.As is a trap because it ignores wrapping and breaks the moment someone adds a layer between your code and the original error.

Deep Dive
Golang channel deadlock

When Golang Channels Kill Your App: Deadlocks, Blocking, and Fixes When Goland code hangs, it doesnt always crash or throw something you can grep. Sometimes a Go service just stops responding — no logs, no...

Scenario Use Why
Check for io.EOF, ErrNotFound errors.Is Identity match through chain
Extract HTTP status from custom type errors.As Type match + field access
Top-level type assertion Neither — avoid Breaks under wrapping

errors.Join and the Multi-Error Trap

Go 1.20 added errors.Join — a way to combine multiple errors into one return value. It looks convenient and it is, until you try to inspect the result with errors.Is or errors.As and get behavior that doesn’t match your mental model. A joined error implements Unwrap() []error — a slice, not a single error. Both errors.Is and errors.As traverse it correctly via depth-first search, but the chain is now a tree, not a list. The trap hits when you wrap a joined error with fmt.Errorf("%w"): the outer wrapper has one Unwrap() error, the joined node underneath has Unwrap() []error — and if your custom error type only implements the single-error variant, the traversal stops before it reaches the joined children.

err1 := fmt.Errorf("validation: %w", ErrBadInput)
err2 := fmt.Errorf("auth: %w", ErrUnauthorized)

joined := errors.Join(err1, err2)

fmt.Println(errors.Is(joined, ErrBadInput))  // true — traverses the slice
fmt.Println(errors.Is(joined, ErrUnauthorized)) // true — finds both

// The trap: wrap joined in a custom type without Unwrap() []error
type BatchError struct{ Err error }
func (e *BatchError) Error() string { return e.Err.Error() }
func (e *BatchError) Unwrap() error { return e.Err } // single-error only — works here

wrapped := &BatchError{Err: joined}
fmt.Println(errors.Is(wrapped, ErrBadInput)) // true — Unwrap() returns joined,
            // stdlib then calls Unwrap() []error on it

The standard library handles this correctly as long as your custom type’s Unwrap() returns the joined error as-is. Where it breaks is when developers manually re-wrap individual errors from the slice and lose the original joined node — at that point the tree collapses and sentinel matching fails silently. Rule of thumb: if you’re collecting errors across a loop, use errors.Join and return it directly. Don’t re-wrap the results into a new fmt.Errorf chain — you’ll flatten the tree and kill traversal.

Sentinel Errors in Public APIs: The Coupling Bomb

Exporting a sentinel error from your package is a public commitment. The moment you write var ErrNotFound = errors.New(“not found”) in a public API and callers start using errors.Is against it, you’ve signed a contract. That sentinel is now part of your interface. Change the behavior it represents, rename it, or stop returning it — and you’ve shipped a breaking change. Most golang sentinel error usage in internal packages is fine. The problem is when it leaks across package boundaries in a way that couples callers to your implementation details.

The io.EOF pattern works because it’s a pure signal with no implementation meaning — it just means “stream ended.” If your ErrNotFound is actually wrapping a sql.ErrNoRows and you plan to swap the storage layer someday, exposing that sentinel locks your architecture in place. Callers write errors.Is(err, yourpkg.ErrNotFound) and now you can’t change what’s underneath without silently breaking them or cutting a major version.

When Sentinels Are the Right Call

Sentinels make sense when the error condition is a stable, meaningful signal that callers legitimately need to branch on — and when the signal is independent of your implementation. io.EOF, context.Canceled, context.DeadlineExceeded — these are signals, not implementation leaks. If your error represents “the database row didn’t exist” rather than “this semantic condition occurred,” you’re probably exporting too much.

The Panic-as-Error Mistake

Golang panic vs error is not a matter of preference — it’s a contract about recoverability. panic is for invariant violations that should never happen in a correctly written program: index out of bounds on an internal data structure, a nil pointer that represents a programming error, an impossible state in a state machine. It is not a substitute for returning an error from a function that might legitimately fail. When a junior dev reaches for panic because “the function can only fail if something is really wrong,” they’ve made a judgment call that belongs to the caller, not the function.

Technical Reference
Goroutine Leak Patterns

Goroutine Leak Patterns That Kill Your Service Without Warning A goroutine leak is a goroutine that was spawned and never terminated — it holds stack memory, blocks on a channel or syscall, and the Go...

recover() doesn’t fix this. You can catch a panic in a deferred function, but you can’t distinguish a programming error panic from a “I used panic instead of error” panic. The calling code can’t handle it cleanly, can’t retry, can’t log it properly, can’t apply backpressure. Production systems that use panic as a control flow mechanism have goroutine stacks in their logs where error messages should be. The rule is direct: if the caller could reasonably handle it, return an error.

// ACCEPTABLE — programming invariant, impossible in correct code
func NewRouter(routes []Route) *Router {
 if len(routes) == 0 {
 panic("NewRouter: routes must not be empty") // config bug, not runtime failure
 }
 return &Router{routes: routes}
}

// RED FLAG — runtime failure the caller could handle
func (s *UserService) GetUser(id string) (*User, error) {
 u, ok := s.cache[id]
 if !ok {
 panic("user not found") // forces every caller to use recover()
 }
 return u, nil
}

// CORRECT
func (s *UserService) GetUser(id string) (*User, error) {
 u, ok := s.cache[id]
 if !ok {
 return nil, fmt.Errorf("GetUser %s: %w", id, ErrNotFound)
 }
 return u, nil
}

The tell on code review is simple: if the function signature says error in the return type, there is no reason to panic inside it. If the function has no error return and something truly impossible happens — that’s the narrow window where panic is honest. Anything that touches network, disk, cache, or external state belongs in the error return, not in a deferred recover chain that the caller never asked for.

Error Wrapping as API Contract: The Decision Nobody Talks About

Golang error wrapping best practices almost universally focus on syntax — use %w, implement Unwrap(), chain your errors. What gets skipped is the architectural consequence. Every time you wrap an error with %w, you’re making a public promise to whoever calls your function: “this underlying error is part of my interface, you can depend on it.” If you wrap sql.ErrNoRows with %w and expose it from your repository layer, callers can and will write errors.Is checks against it. You’ve just welded your storage implementation to your API.

The alternative — wrapping for context but hiding the cause — means using a custom error type or returning a new sentinel that belongs to your package. This costs more upfront and requires deliberate design. But it’s the difference between a repository interface that can swap between Postgres, SQLite, and an in-memory store without touching callers, and one that can’t.

Custom Error Types: Struct vs String vs Sentinel

The golang custom error type decision follows a straightforward priority: start with the simplest thing that gives callers what they need. If they only need to know something went wrong with no branching — errors.New or fmt.Errorf without %w. If they need context and chain traversal — fmt.Errorf with %w. If they need data fields — a struct implementing the error interface. The mistake is jumping to a custom struct because it “feels more professional,” then not implementing Unwrap() and wondering why the whole errors.Is / errors.As machinery stops working through it.

type APIError struct {
 StatusCode int
 Message string
 Err  error
}

func (e *APIError) Error() string {
 return fmt.Sprintf("API error %d: %s", e.StatusCode, e.Message)
}

// Without this, errors.Is and errors.As can't see through APIError
func (e *APIError) Unwrap() error { return e.Err }

Unwrap() is two lines. Skipping it means every error wrapped inside your type becomes invisible to the standard library traversal functions. This is the most quietly destructive mistake in mid-level Go code — not a crash, not a visible failure, just silent chain termination.

The Forgotten Unwrap() Method

A custom go error struct that holds an inner Err error field without implementing Unwrap() is a chain terminator. errors.Is and errors.As will stop at your type and report no match for anything inside it. In codebases with multiple layers of error wrapping, this produces a class of bug where the sentinel exists in the chain but is invisible to inspection — leading developers to add redundant error checks or restructure code that’s actually fine. The fix is always the same: add Unwrap() error returning the inner error. Two lines. Non-negotiable.

FAQ

What is the difference between golang errors.Is and errors.As in practice?

errors.Is checks whether a specific error value — typically a sentinel like io.EOF or a package-level var — appears anywhere in the error chain by walking Unwrap() recursively. errors.As does the same walk but looks for an error whose type is assignable to a target variable, then assigns it. Use errors.Is when you need to detect a condition. Use errors.As when you need to extract data from a typed error. Confusing the two produces bugs that only appear when errors are wrapped, because direct type assertion won’t traverse the chain at all.

Why does fmt.Errorf %w matter for golang error handling?

%w is what connects fmt.Errorf to the errors package machinery. It stores the original error as a wrapped value that Unwrap() can return, which is exactly what errors.Is and errors.As use to traverse the chain. Without %w — using %v or %s — the error is converted to a plain string and the original value is discarded. The new error cannot be unwrapped. Any sentinel or typed error buried in it becomes permanently inaccessible to errors.Is and errors.As. This is a silent failure: no panic, no compile error, just incorrect boolean results from your error checks.

Worth Reading
Golang Receiver Mistake

Golang Receiver Mistake That Silently Destroys Your Struct You wrote a method, it compiles, tests pass — and the struct still hasn't changed. Or you implemented an interface, and Go tells you it isn't implemented....

When should I use a golang custom error type instead of a sentinel?

Use a custom struct type when callers need to access data from the error beyond just knowing which condition occurred. HTTP status codes, validation field names, retry intervals, database constraint names — these belong in struct fields, not in the error message string. Sentinel errors work when the condition itself is the signal and no additional data is needed. The cost of a custom type is implementing Error() string and Unwrap() error. Skip Unwrap() and your type becomes a dead end in the error chain, which is worse than using a sentinel in the first place.

Is using panic instead of returning an error acceptable in Go?

Panic is appropriate for programming errors — states that should be impossible if the code is correct. An out-of-bounds access on an internal slice, a nil pointer that represents a configuration bug, a violated invariant in a data structure. It is not appropriate for runtime failures that any production system should expect: network timeouts, missing records, permission errors, external API failures. Using panic for these forces every caller to use recover() for basic error handling, which destroys the composability that Go’s error model provides. If the failure is something a caller could meaningfully handle, return an error.

How do sentinel errors in a public Go API create coupling problems?

When you export a sentinel from your package and callers write errors.Is checks against it, that sentinel becomes part of your public interface whether you intended it or not. Changing what the sentinel represents, removing it, or changing the conditions under which it’s returned is a breaking change — even if it’s not documented as part of your API. The deeper trap is when your sentinel wraps an implementation detail like sql.ErrNoRows through %w: now callers can potentially detect the inner error too, coupling them to your storage choice. In stable internal packages this is manageable. In public APIs it’s a maintenance liability that compounds over major versions.

What breaks when a custom error type doesn’t implement Unwrap()?

Without Unwrap(), errors.Is and errors.As treat your type as an opaque terminal node. They check whether your error matches the target, and if not, they stop — they can’t see anything wrapped inside it. Any sentinel or typed error your struct wraps becomes invisible to standard library error inspection. In practice this produces cases where you know an error is in the chain because you wrapped it yourself, but errors.Is returns false and you can’t figure out why. The issue appears in medium-to-large codebases where error types cross package boundaries and the wrapping layers stack up. Adding Unwrap() returning the inner error field fixes it completely.

— Written by a backend engineer who has debugged enough “why is errors.Is returning false” incidents to have opinions about it.

Get the Error Chain Right Once — or Debug It Forever

I’ve reviewed Go error handling internals in codebases ranging from 10k to 10M lines. The pattern is always the same: %v instead of %w somewhere three layers deep, a custom error type missing Unwrap(), and a sentinel that escaped into a public API six months ago. None of these are exotic bugs. They’re the default outcome when golang error wrapping is treated as syntax rather than a chain contract.

errors.Is returning false when you’re certain the sentinel is there is not a mystery — it’s a missing %w or a dead-end struct. errors.As failing on a wrapped type is not a Go quirk — it’s a missing Unwrap(). These failures are deterministic and completely preventable. The golang custom error type decision, the sentinel coupling question, the panic vs error boundary — none of it is complicated once you accept that every wrapping decision is an API decision.

Get the chain right once, and your error handling becomes the most readable part of your codebase. Get it wrong, and you’ll be the person adding fmt.Println(err) at every layer trying to figure out where the context disappeared.

Written by:

Source Category: Goland Internals