Fix: Go Error Handling Not Working — errors.Is, errors.As, and Wrapping
Quick Answer
How to fix Go error handling — errors.Is vs ==, errors.As for type extraction, fmt.Errorf %w for wrapping, sentinel errors, custom error types, and stack traces.
The Problem
errors.Is returns false even though the error matches:
var ErrNotFound = errors.New("not found")
func getUser(id int) error {
return fmt.Errorf("user service: %v", ErrNotFound) // Wrapping with %v
}
err := getUser(42)
fmt.Println(errors.Is(err, ErrNotFound)) // false — wrapping with %v loses the chainOr errors.As fails to extract the custom error type:
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error on %s: %s", e.Field, e.Message)
}
err := validate(input)
var ve ValidationError // Not a pointer — wrong type for errors.As
if errors.As(err, &ve) {
fmt.Println(ve.Field) // Never reached — errors.As returns false
}Or error comparison with == breaks after wrapping:
if err == sql.ErrNoRows { // false after wrapping
// Handle not found
}Why This Happens
Go 1.13 introduced error wrapping via fmt.Errorf with the %w verb. The errors.Is and errors.As functions unwrap errors recursively — but only if the error was wrapped with %w, not %v or %s.
Key distinctions:
%wwraps the error —errors.Isanderrors.Ascan unwrap through it. The wrapped error is accessible viaerrors.Unwrap().%vand%sformat the error as a string — the original error is lost.errors.Isanderrors.Ascan’t unwrap through a stringified error.==comparison only works for identical pointers — sentinel errors (var ErrNotFound = errors.New(...)) are pointer values. A wrapped error is a different pointer, so==fails.errors.Asrequires a pointer to the target type — ifValidationErrorimplementserrorwith a pointer receiver (*ValidationError),errors.Asneeds*ValidationErroras the target.
Fix 1: Use %w to Wrap Errors
Replace %v and %s with %w when adding context to errors that need to be inspectable:
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
var ErrPermission = errors.New("permission denied")
// WRONG — %v serializes the error as string, loses the chain
func getUserWrong(id int) error {
return fmt.Errorf("user service: %v", ErrNotFound)
}
// CORRECT — %w wraps the error, preserves the chain
func getUser(id int) error {
return fmt.Errorf("user service: %w", ErrNotFound)
}
// Works correctly:
err := getUser(42)
fmt.Println(errors.Is(err, ErrNotFound)) // true — %w preserves the chain
fmt.Println(err.Error()) // "user service: not found"
// Multi-level wrapping — errors.Is unwraps recursively
func getProfile(userID int) error {
if err := getUser(userID); err != nil {
return fmt.Errorf("profile service: %w", err)
}
return nil
}
err = getProfile(42)
fmt.Println(errors.Is(err, ErrNotFound)) // true — unwraps through both layersWhen to use %w vs %v:
- Use
%wwhen the caller needs to inspect the error type or compare against sentinel values. - Use
%vor%swhen you’re logging and don’t need the caller to inspect the error further — or when you intentionally want to hide implementation details.
// Service layer — wrap to preserve error chain
func (s *UserService) Get(id int) (*User, error) {
user, err := s.repo.FindByID(id)
if err != nil {
return nil, fmt.Errorf("UserService.Get(%d): %w", id, err)
}
return user, nil
}
// HTTP handler — only log, don't need the chain to propagate
func handleGetUser(w http.ResponseWriter, r *http.Request) {
user, err := userService.Get(id)
if err != nil {
log.Printf("failed to get user: %v", err) // %v is fine here — just logging
http.Error(w, "internal server error", 500)
return
}
json.NewEncoder(w).Encode(user)
}Fix 2: Use errors.Is for Sentinel Error Comparison
Replace == comparisons with errors.Is for wrapped errors:
import (
"database/sql"
"errors"
)
// WRONG — == breaks after wrapping
func getUserWrong(db *sql.DB, id int) (*User, error) {
row := db.QueryRow("SELECT * FROM users WHERE id = ?", id)
var user User
if err := row.Scan(&user.ID, &user.Name); err != nil {
if err == sql.ErrNoRows { // Only works if err is exactly sql.ErrNoRows
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("scan: %v", err)
}
return &user, nil
}
// CORRECT — errors.Is unwraps through the chain
func getUser(db *sql.DB, id int) (*User, error) {
row := db.QueryRow("SELECT * FROM users WHERE id = ?", id)
var user User
if err := row.Scan(&user.ID, &user.Name); err != nil {
if errors.Is(err, sql.ErrNoRows) { // Works even if err is wrapped
return nil, fmt.Errorf("user %d: %w", id, ErrUserNotFound)
}
return nil, fmt.Errorf("query user %d: %w", id, err)
}
return &user, nil
}
// Caller uses errors.Is to check the specific error condition
user, err := getUser(db, 42)
if errors.Is(err, ErrUserNotFound) {
// Handle "not found" specifically
http.Error(w, "user not found", http.StatusNotFound)
return
}
if err != nil {
// Handle other errors
http.Error(w, "internal error", http.StatusInternalServerError)
return
}Custom Is method for complex sentinel logic:
type StatusError struct {
Code int
Message string
}
func (e *StatusError) Error() string {
return fmt.Sprintf("status %d: %s", e.Code, e.Message)
}
// Custom Is — match by status code regardless of message
func (e *StatusError) Is(target error) bool {
t, ok := target.(*StatusError)
if !ok {
return false
}
return e.Code == t.Code // Match if codes are equal
}
var ErrNotFound = &StatusError{Code: 404}
var ErrForbidden = &StatusError{Code: 403}
err := &StatusError{Code: 404, Message: "user not found"}
fmt.Println(errors.Is(err, ErrNotFound)) // true — same code, custom Is methodFix 3: Use errors.As to Extract Error Types
errors.As finds the first error in the chain that matches the target type:
// Custom error type with pointer receiver
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
// Function that returns a wrapped ValidationError
func validate(email string) error {
if !strings.Contains(email, "@") {
return fmt.Errorf("validate: %w", &ValidationError{
Field: "email",
Message: "must contain @",
})
}
return nil
}
err := validate("notanemail")
// WRONG — value type, not pointer type
var ve ValidationError
if errors.As(err, &ve) { // false — ValidationError implements error as *ValidationError
fmt.Println(ve.Field)
}
// CORRECT — pointer type
var ve *ValidationError
if errors.As(err, &ve) { // true — errors.As finds *ValidationError in the chain
fmt.Println(ve.Field) // "email"
fmt.Println(ve.Message) // "must contain @"
}Extract multiple error types from a chain:
// Check for specific types at different layers
func handleError(err error) {
var netErr *net.OpError
var dnsErr *net.DNSError
var ve *ValidationError
switch {
case errors.As(err, &ve):
fmt.Printf("Validation failed on field %s: %s\n", ve.Field, ve.Message)
case errors.As(err, &dnsErr):
fmt.Printf("DNS error: %s\n", dnsErr.Name)
case errors.As(err, &netErr):
fmt.Printf("Network error: %s\n", netErr.Op)
default:
fmt.Printf("Unknown error: %v\n", err)
}
}Fix 4: Implement the Unwrap Method for Custom Error Types
If you build a custom error type that wraps another error, implement Unwrap() so errors.Is and errors.As can traverse the chain:
type AppError struct {
Code string
Message string
Err error // The wrapped error
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}
// Unwrap — enables errors.Is and errors.As to traverse through AppError
func (e *AppError) Unwrap() error {
return e.Err
}
// Usage
var ErrDBConnection = errors.New("database connection failed")
func connectDB() error {
return &AppError{
Code: "DB_001",
Message: "cannot connect",
Err: ErrDBConnection,
}
}
err := connectDB()
fmt.Println(errors.Is(err, ErrDBConnection)) // true — Unwrap() traverses AppErrorWrapping multiple errors (Go 1.20+):
// Go 1.20 — join multiple errors
err1 := errors.New("database error")
err2 := errors.New("cache error")
combined := errors.Join(err1, err2)
fmt.Println(errors.Is(combined, err1)) // true
fmt.Println(errors.Is(combined, err2)) // true
fmt.Println(combined.Error()) // "database error\ncache error"
// fmt.Errorf with multiple %w (Go 1.20+)
combined2 := fmt.Errorf("failed: %w; also: %w", err1, err2)
fmt.Println(errors.Is(combined2, err1)) // true
fmt.Println(errors.Is(combined2, err2)) // trueFix 5: Add Stack Traces to Errors
Go’s standard library doesn’t capture stack traces automatically. Use github.com/pkg/errors or golang.org/x/xerrors for stack traces:
import "github.com/pkg/errors"
// Wrap with stack trace (only at the origin — not at every wrapping level)
func readConfig(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return errors.Wrap(err, "read config") // Captures stack trace here
}
return nil
}
// Add context without a new stack frame
func loadApp() error {
if err := readConfig("config.yaml"); err != nil {
return errors.WithMessage(err, "load app") // Adds context, no new trace
}
return nil
}
// Print with stack trace
err := loadApp()
if err != nil {
fmt.Printf("%+v\n", err) // %+v prints the full stack trace
}With slog or zerolog — log the error with context:
import "log/slog"
if err != nil {
slog.Error("operation failed",
"error", err,
"user_id", userID,
"operation", "getUser",
)
}Fix 6: Sentinel Error Pattern
Define sentinel errors as package-level variables so callers can compare against them:
// errors.go — package-level sentinel errors
package user
import "errors"
// Use var + errors.New, not const — errors.New returns a pointer
var (
ErrNotFound = errors.New("user not found")
ErrInvalidID = errors.New("invalid user ID")
ErrPermission = errors.New("insufficient permissions")
ErrEmailTaken = errors.New("email address already in use")
)
// Wrap sentinels with context
func GetByEmail(email string) (*User, error) {
// ...
if notFound {
return nil, fmt.Errorf("GetByEmail(%q): %w", email, ErrNotFound)
}
return user, nil
}// Caller — use errors.Is for comparison
user, err := user.GetByEmail("[email protected]")
switch {
case errors.Is(err, user.ErrNotFound):
http.Error(w, "user not found", http.StatusNotFound)
case errors.Is(err, user.ErrPermission):
http.Error(w, "forbidden", http.StatusForbidden)
case err != nil:
slog.Error("unexpected error", "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
}Fix 7: Test Error Handling
Verify that error wrapping and comparison work correctly in tests:
package user_test
import (
"errors"
"testing"
"myapp/user"
)
func TestGetByEmail_NotFound(t *testing.T) {
svc := newTestService(t) // Set up with empty database
_, err := svc.GetByEmail("[email protected]")
// Verify the correct sentinel error is returned (even if wrapped)
if !errors.Is(err, user.ErrNotFound) {
t.Errorf("expected ErrNotFound, got: %v", err)
}
}
func TestValidate_Returns_ValidationError(t *testing.T) {
err := validate("")
var ve *ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected ValidationError, got: %T: %v", err, err)
}
if ve.Field != "email" {
t.Errorf("expected Field 'email', got %q", ve.Field)
}
}Still Not Working?
Third-party errors not wrapping — some libraries return errors that don’t support Unwrap(). If errors.Is(err, targetErr) fails with a library error, check if the library provides its own comparison function (e.g., grpc/status.Code(err) instead of errors.Is).
panic vs error — Go’s convention is to use errors for expected failure conditions and panic only for truly unexpected states (programming errors, unrecoverable conditions). Don’t recover from panics to return errors unless you’re in a framework boundary (like an HTTP handler’s middleware).
Ignoring errors — _ = someFunc() silently discards errors. Use //nolint:errcheck with justification if truly intentional, and run errcheck or staticcheck in CI to catch discarded errors.
Error messages should not be capitalized or end with punctuation — Go convention: error strings are lowercase and don’t end with . or !. They’re often used in larger error messages: fmt.Errorf("open file: %w", err) reads naturally as a sentence fragment.
For related Go issues, see Fix: Go Goroutine Leak and Fix: Go Interface Nil Panic.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Go Test Not Working — Tests Not Running, Failing Unexpectedly, or Coverage Not Collected
How to fix Go testing issues — test function naming, table-driven tests, t.Run subtests, httptest, testify assertions, and common go test flag errors.
Fix: Go Generics Type Constraint Error — Does Not Implement or Cannot Use as Type
How to fix Go generics errors — type constraints, interface vs constraint, comparable, union types, type inference failures, and common generic function pitfalls.
Fix: Go Panic Not Recovered — panic/recover Patterns and Common Pitfalls
How to handle Go panics correctly — recover() placement, goroutine panics, HTTP middleware recovery, defer ordering, distinguishing panics from errors, and when not to use recover.
Fix: Java Record Not Working — Compact Constructor Error, Serialization Fails, or Cannot Extend
How to fix Java record issues — compact constructor validation, custom accessor methods, Jackson serialization, inheritance restrictions, and when to use records vs regular classes.