sch39
Part 6 of 6 — Go Fundamentals for Developers

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 %w without breaking the error chain.

  • Use errors.Is and errors.As to inspect errors reliably.

Implementation

Folder Structure

text
go-series/G01-06-error-handling/
├── go.mod
└── faults/
    ├── faults.go
    └── faults_test.go

Setup

Initialize one module (run from the go-series/ folder):

bash
mkdir -p G01-06-error-handling
cd G01-06-error-handling

go mod init github.com/sch39/go-series/g0106/errors

Code Blocks

Block 1: Sentinel error — a stable condition for callers

go-series/G01-06-error-handling/faults/faults.go:

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:

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:

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:

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:

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:

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:

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:

bash
go test -count=1 -v ./...

Output:

text
=== 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.448s

Why 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 errors.Is

Does not carry per-occurrence details

Typed error

Structured details available via errors.As

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 %w if the cause needs to remain inspectable.

  • Checking an error? Use errors.Is or errors.As, not string comparison.

  • Success path? Return nil explicitly.

References