Error Handling in Go: Wrapping, Checking, and Sentinel Errors
Oct 2026 · 4 min read
Errors in Go are values that can be given context while preserving their cause using `%w`. Use `errors.Is` to match sentinel errors and `errors.As` to extract typed errors from an error chain without relying on message text.
TL;DR: Errors in Go are values that can be given context while preserving their cause using %w. Use errors.Is to match sentinel errors and errors.As to extract typed errors from an error chain without relying on message text. Full code: https://github.com/Sch39/go-series
Problem
Comparing error text breaks easily when context is added or messages change. Replacing the cause with a new message also prevents callers from making decisions based on the original error.
Outcome
Readers can:
Distinguish sentinel errors and typed errors based on the caller's needs.
Add context with
%wwithout breaking the error chain.Use
errors.Isanderrors.Asto inspect errors reliably.
Implementation
Folder Structure
go-series/G01-06-error-handling/
├── go.mod
└── faults/
├── faults.go
└── faults_test.goSetup
Initialize one module (run from the go-series/ folder):
mkdir -p G01-06-error-handling
cd G01-06-error-handling
go mod init github.com/sch39/go-series/g0106/errorsCode Blocks
Block 1: Sentinel error — a stable condition for callers
go-series/G01-06-error-handling/faults/faults.go:
package faults
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("record not found")
func FindUser(id int) error {
if id != 42 {
return ErrNotFound
}
return nil
}Goal: Provide the ErrNotFound sentinel as an identity for a condition that callers can check.
Block 2: Error wrapping — context without losing the cause
go-series/G01-06-error-handling/faults/faults.go:
func LoadProfile(id int) error {
if err := FindUser(id); err != nil {
return fmt.Errorf("load profile %d: %w", id, err)
}
return nil
}Goal: Add operation context with %w while preserving ErrNotFound in the error chain.
Block 3: Typed error — structured details
go-series/G01-06-error-handling/faults/faults.go:
type ValidationError struct {
Field string
Value string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("invalid %s %q", e.Field, e.Value)
}
func ValidateEmail(email string) error {
if email == "" {
return &ValidationError{Field: "email", Value: email}
}
return nil
}
func Register(email string) error {
if err := ValidateEmail(email); err != nil {
return fmt.Errorf("register user: %w", err)
}
return nil
}Goal: Carry the failing field and value as structured data in ValidationError that can still be wrapped.
Block 4: Error chain inspection — Is and As
go-series/G01-06-error-handling/faults/faults.go:
func IsNotFound(err error) bool {
return errors.Is(err, ErrNotFound)
}
func ValidationDetails(err error) (*ValidationError, bool) {
var validationErr *ValidationError
if !errors.As(err, &validationErr) {
return nil, false
}
return validationErr, true
}Goal: Check sentinel identity and extract typed errors by walking the entire error chain.
Test Blocks
Test 1: Direct and wrapped sentinel
go-series/G01-06-error-handling/faults/faults_test.go:
package faults_test
import (
"testing"
"github.com/sch39/go-series/g0106/errors/faults"
)
func TestSentinelAndWrappedError(t *testing.T) {
directErr := faults.FindUser(7)
wrappedErr := faults.LoadProfile(7)
directMatch := faults.IsNotFound(directErr)
wrappedMatch := faults.IsNotFound(wrappedErr)
t.Logf("direct=%q match=%t", directErr, directMatch)
t.Logf("wrapped=%q match=%t", wrappedErr, wrappedMatch)
if !directMatch {
t.Error("FindUser() error should match ErrNotFound")
}
if !wrappedMatch {
t.Error("LoadProfile() wrapped error should match ErrNotFound")
}
}Goal: Prove that errors.Is recognizes the sentinel both as a direct error and as a wrapped cause.
Test 2: Typed error inside the error chain
go-series/G01-06-error-handling/faults/faults_test.go:
func TestTypedErrorWithErrorsAs(t *testing.T) {
err := faults.Register("")
validationErr, ok := faults.ValidationDetails(err)
t.Logf("error=%q matched=%t", err, ok)
if ok {
t.Logf("field=%q value=%q", validationErr.Field, validationErr.Value)
}
if !ok {
t.Fatal("Register() error should contain *ValidationError")
}
if validationErr.Field != "email" || validationErr.Value != "" {
t.Errorf("ValidationError = %+v, want email with empty value", validationErr)
}
}Goal: Prove that errors.As finds *ValidationError and its details behind the wrapping.
Test 3: The success path returns nil
go-series/G01-06-error-handling/faults/faults_test.go:
func TestSuccessReturnsNil(t *testing.T) {
findErr := faults.FindUser(42)
registerErr := faults.Register("dev@example.com")
t.Logf("findErr=%v registerErr=%v", findErr, registerErr)
if findErr != nil || registerErr != nil {
t.Errorf("success should return nil: find=%v register=%v", findErr, registerErr)
}
}Goal: Prove that a successful operation returns an error interface whose value is nil.
Run Test
Run from the go-series/G01-06-error-handling/ folder:
go test -count=1 -v ./...Output:
=== RUN TestSentinelAndWrappedError
faults_test.go:15: direct="record not found" match=true
faults_test.go:16: wrapped="load profile 7: record not found" match=true
--- PASS: TestSentinelAndWrappedError (0.00s)
=== RUN TestTypedErrorWithErrorsAs
faults_test.go:30: error="register user: invalid email \"\"" matched=true
faults_test.go:32: field="email" value=""
--- PASS: TestTypedErrorWithErrorsAs (0.00s)
=== RUN TestSuccessReturnsNil
faults_test.go:47: findErr=<nil> registerErr=<nil>
--- PASS: TestSuccessReturnsNil (0.00s)
PASS
ok github.com/sch39/go-series/g0106/errors/faults 3.448sWhy It Works
Errors as values: Any type that has an Error() string method satisfies the error interface. A sentinel uses a single shared value to mark a stable condition, while a typed error stores the details the caller needs.
Wrapping with %w: fmt.Errorf with %w produces an error that provides a cause for the unwrapping process. The message gets new context, but errors.Is and errors.As can still walk to the original error.
errors.Is: This function checks whether any error in the chain matches the target. Because LoadProfile wraps ErrNotFound, the check still succeeds without comparing text.
errors.As: This function finds the first error in the chain that is assignable to the target type, then fills the target. A pointer to a *ValidationError variable lets the caller read Field and Value from the wrapped cause.
Edge Cases
%v is not %w: fmt.Errorf("context: %v", err) only copies the text and does not preserve the error in the chain, so errors.Is and errors.As cannot reach the cause.
The errors.As target must be valid: The target must be a non-nil pointer to a type that implements error, or to an interface; an invalid target causes a panic.
A sentinel becomes part of the API: Exposing a sentinel makes that error's identity a contract the package may need to maintain.
Trade-offs
Strategy | Pro | Con |
|---|---|---|
Sentinel error | Simple condition checks with | Does not carry per-occurrence details |
Typed error | Structured details available via | Adds a type that becomes part of the package contract |
Plain message error | Quick for internal context | Callers have no stable way to make decisions |
Best practice: Add context at every meaningful boundary with %w, use sentinels for conditions without details, and use typed errors when callers need to read structured data.
Production Checklist
Do callers need to recognize a specific condition? Provide a stable sentinel or typed error.
Adding context to an error? Use
%wif the cause needs to remain inspectable.Checking an error? Use
errors.Isorerrors.As, not string comparison.Success path? Return
nilexplicitly.
References
Go 1.13 Release Notes, Error wrapping: https://go.dev/doc/go1.13#error_wrapping
Package errors: https://pkg.go.dev/errors
Package fmt, Errorf: https://pkg.go.dev/fmt#Errorf
Error handling and Go: https://go.dev/blog/error-handling-and-go