Error Wrapping (Is / As)

intermediate go errors wrapping

Error wrapping means putting one error inside another, so we can add context (“while loading config: …”) without throwing away the original error underneath.

This landed in Go 1.13, and it fixed a real pain: before it, adding context meant mangling the error into a string and losing the ability to inspect it later.

The old, bad way: string concatenation

Before wrapping, people did this:

if err != nil {
	return fmt.Errorf("loading config failed: %s", err.Error()) // err becomes a string
}

The problem? Now the original error is just text. We’ve flattened it. We can never again ask “was this a file-not-found error?” or “what was the original error type?” — that information is gone forever.

The new way: wrap with %w

fmt.Errorf has a special verb, %w (for “wrap”). It adds our context message and keeps a link to the original error underneath.

import "fmt"

func loadConfig() error {
	err := os.ErrNotExist // pretend a file was missing
	if err != nil {
		// %w keeps err reachable inside the new error
		return fmt.Errorf("loading config: %w", err)
	}
	return nil
}

Think of it like nesting boxes. Each layer adds a label on the outside, but the original item is still in there, fully intact. We can open the boxes one at a time.

errors.Unwrap — peel one layer

errors.Unwrap pulls out the error that was wrapped, one level down. We rarely call it directly, but it’s what powers Is and As under the hood.

wrapped := fmt.Errorf("outer: %w", os.ErrNotExist)
inner := errors.Unwrap(wrapped) // returns os.ErrNotExist

errors.Is — “is this (anywhere in the chain) that specific error?”

When we want to check against a known sentinel error (a predefined error value like os.ErrNotExist or sql.ErrNoRows), we use errors.Is. It walks the whole chain, unwrapping as it goes, so it finds the target even if it’s buried five layers deep.

This is why we use errors.Is instead of == now — plain == only checks the outer error and misses wrapped ones.

import "errors"

err := loadConfig() // returns "loading config: file does not exist"
if errors.Is(err, os.ErrNotExist) { // checks the whole chain
	fmt.Println("the file was missing — let's create a default")
}

errors.As — “is there an error of this TYPE in the chain? give it to me”

Sometimes we don’t just want to know which error it is — we want the actual typed error so we can read its fields. That’s errors.As. It searches the chain for an error of a given type and, if found, fills in our variable so we can use it.

import "errors"

type ValidationError struct {
	Field string
}
func (e *ValidationError) Error() string { return "invalid field: " + e.Field }

// somewhere up the chain a *ValidationError got wrapped...
var ve *ValidationError
if errors.As(err, &ve) { // pass a POINTER to the target variable
	// found it — now we can read its fields
	fmt.Println("bad field was:", ve.Field)
}

The simple rule of thumb:

  • Use errors.Is to compare against a specific value (a sentinel).
  • Use errors.As to extract a specific type (so you can read its fields).
The wrapped error chain — Is/As walk it from outside in
"loading config: ..."  (outer)
"open file: ..."  (middle)
os.ErrNotExist  ← errors.Is finds this here
errors.Unwrap peels one box at a time · Is/As keep peeling until match or end

Why wrapping beats string concatenation

  • We keep the original error — both its value and its type survive, so Is and As still work.
  • We get a readable trail — printing the final error shows the full context: "loading config: open file: file does not exist".
  • Callers stay decoupled — a top-level handler can check errors.Is(err, sql.ErrNoRows) without caring how many layers wrapped it on the way up.

One caution: wrapping with %w makes the inner error part of your public API — callers can now depend on it. If you don’t want that coupling, use %v instead of %w to include the message as plain text without exposing the wrapped value.

Interview soundbite

Since Go 1.13 we wrap errors with fmt.Errorf("...: %w", err), which adds context while preserving the original error’s value and type. We then inspect the chain with errors.Is to match a specific sentinel value and errors.As to extract a specific error type so we can read its fields. This beats string concatenation because the underlying error survives intact, so callers can still reason about what failed no matter how many layers wrapped it.