Error Handling: The error Interface, Error Wrapping and Sentinel Errors
Module 2: Composite Data Types & Object-Oriented Design | Lesson: m02-l05 | Level: Beginner After this lesson you will be able to: use Go's standard error handling pattern, understand the error chain mechanism, and correctly use panic/recover for unrecoverable exceptions.
Learning Objectives
- Explain the definition of the
errorinterface and its central role in Go error handling. - Use
fmt.Errorfwith%wto wrap errors, and useerrors.Isanderrors.Asto inspect the error chain. - Distinguish sentinel errors from custom error types, and understand when to use
errors.Join. - Correctly understand the boundaries of
panicandrecover, and avoid abusing them to crash the program.
Core Concepts
2.1 The error Interface and Custom Error Types
error is the standard interface Go uses to represent an error state. It is a predeclared identifier (listed in the builtin docs, no import needed) and contains exactly one method:
type error interface {
Error() string
}
Why this design? Go has no exception class hierarchy like Java or Python. Errors are ordinary values that can be passed, stored and compared like any other data. This design makes error handling explicit and controlled, avoiding the tangled control flow that implicit exception catching brings.
Custom error types
Although errors.New can create simple errors, in production we usually need to carry more information (such as an HTTP status code, an error code, a stack trace, etc.). For that we define custom structs that implement the error interface.
To support finer-grained error matching, a custom error type can implement an Is(error) bool method. When this method exists, errors.Is calls it preferentially when checking that error type, allowing a custom error to logically match a sentinel error rather than relying solely on pointer equality.
type MyError struct {
Code int
Msg string
}
func (e *MyError) Error() string {
return fmt.Sprintf("code=%d, msg=%s", e.Code, e.Msg)
}
// Optional: implement Is to support logical matching in errors.Is
func (e *MyError) Is(target error) bool {
// e.g., match a specific sentinel error
return target == ErrSpecificError
}
2.2 Error Wrapping and the Error Chain
In multi-layer function calls, an error returned by a low-level function often needs to be passed upward. If it is returned as-is, the caller loses the original context (for example, whether the "database query failed" was caused by a "connection timeout" or a "SQL syntax error").
Error wrapping
Go 1.13 introduced error wrapping. Using fmt.Errorf with the %w verb wraps the original error inside a new error while keeping a reference to the original error.
// %w was introduced in Go 1.13
err := fmt.Errorf("failed to process: %w", originalErr)
Improvement in Go 1.20
Starting in Go 1.20, fmt.Errorf allows multiple %w verbs in the same format string, supporting more complex error chains.
Inspecting the error chain: errors.Is and errors.As
- errors.Is(err, target error) bool: walks up err's wrap chain and reports whether any error in the chain is equal to target. It is mainly used to check for sentinel errors.
- errors.As(err, target interface{}) bool: walks up err's wrap chain, finds the first error that can be assigned to the type target points to, and writes it into target. target must be a non-nil pointer to some error type or to the error interface. It is mainly used to extract specific fields from a custom error.
Sentinel errors
A sentinel error is a predeclared, globally unique error variable, usually representing a specific business error state. Because errors.Is uses pointer equality for pointer types, sentinel errors are typically defined as package-level variables.
errors.Join (Go 1.20+)
When you need to return several errors at once (for example, partial failures in a batch operation), use errors.Join. It returns a composite error containing all of them, and errors.Is can match any one of them.
2.3 The Boundaries of panic and recover
panic triggers a program crash and recover restores from it. They are typically used for unrecoverable exceptions (such as nil pointer dereference, array index out of bounds), not for business logic errors.
Two hard rules for recover
1. Must be called directly: recover only works when called directly inside a deferred function. If recover is wrapped inside another ordinary function, it returns nil and cannot catch the panic.
2. Does not cross goroutines: a panic unwinds only the current goroutine's call stack. Other goroutines cannot recover this goroutine's panic. Therefore every goroutine that might panic must carry its own defer/recover.
Example Code
Example 1: Error Wrapping and Inspection
This example shows how to wrap errors with fmt.Errorf and inspect the chain with errors.Is and errors.As.
package main
import (
"errors"
"fmt"
)
// Sentinel error: record not found
var ErrRecordNotFound = errors.New("record not found")
// Custom error type: a database error
type DatabaseError struct {
Code int
Msg string
}
func (e *DatabaseError) Error() string {
return fmt.Sprintf("db error: code=%d, msg=%s", e.Code, e.Msg)
}
// queryDatabase simulates a database query and may return a sentinel or a custom error
func queryDatabase(id int) error {
if id == 404 {
return ErrRecordNotFound
}
if id == 500 {
return &DatabaseError{Code: 500, Msg: "internal server error"}
}
return nil
}
func main() {
// Scenario 1: check a sentinel error
err := queryDatabase(404)
if errors.Is(err, ErrRecordNotFound) {
fmt.Println("Caught sentinel error: record not found")
}
// Scenario 2: check a custom error type
err = queryDatabase(500)
var dbErr *DatabaseError
if errors.As(err, &dbErr) {
fmt.Printf("Caught custom error: code=%d\n", dbErr.Code)
}
// Scenario 3: error wrapping and chained inspection
wrappedErr := fmt.Errorf("failed to query: %w", err)
// The correct approach: use errors.As to check whether the wrap chain contains DatabaseError
var targetDBErr *DatabaseError
if errors.As(wrappedErr, &targetDBErr) {
fmt.Printf("Wrapped error contains DatabaseError: code=%d\n", targetDBErr.Code)
}
}
Expected output:
Caught sentinel error: record not found
Caught custom error: code=500
Wrapped error contains DatabaseError: code=500
Example 2: The Boundaries of panic and recover
This example shows the correct usage of recover, the requirement to call it directly, and the fact that it does not cross goroutines.
package main
import (
"fmt"
"sync"
)
// safeFunc: recover works when called directly in a defer
func safeFunc() {
defer func() {
if r := recover(); r != nil {
fmt.Printf("Recovered in safeFunc: %v\n", r)
}
}()
panic("panic in safeFunc")
}
// recoverInNormalFunc: recover is NOT called directly in a defer
func recoverInNormalFunc() interface{} {
return recover() // returns nil, because no panic is currently unwinding
}
// goroutinePanic: panic does not cross goroutines
func goroutinePanic() {
defer func() {
if r := recover(); r != nil {
fmt.Printf("Recovered in main goroutine: %v\n", r)
}
}()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
fmt.Printf("Recovered in child goroutine: %v\n", r)
}
}()
panic("panic in child goroutine")
}()
wg.Wait()
}
func main() {
fmt.Println("=== Test 1: Direct defer recover ===")
safeFunc()
fmt.Println("\n=== Test 2: Recover in normal function ===")
// Calling recoverInNormalFunc does not catch any panic, because it is not a defer.
// We do not panic here, so recover returns nil.
r := recoverInNormalFunc()
fmt.Printf("Recover result: %v\n", r)
fmt.Println("\n=== Test 3: Goroutine panic ===")
goroutinePanic()
}
Expected output:
=== Test 1: Direct defer recover ===
Recovered in safeFunc: panic in safeFunc
=== Test 2: Recover in normal function ===
Recover result: <nil>
=== Test 3: Goroutine panic ===
Recovered in child goroutine: panic in child goroutine
Common Mistakes & Pitfalls
-
Bad code: using
errors.Is(err, &MyError{})to check a custom error. Why:errors.Isuses pointer equality for pointer types.&MyError{}creates a new pointer every time it is evaluated, which is not equal to the wrapped error's pointer. Correct approach: useerrors.As(err, &target)to check the type, or define a global sentinel error variable and useerrors.Is. -
Bad code: calling
recover()inside an ordinary function to try to catch a panic. Why:recoveronly works inside a function called directly bydefer. Ifrecoveris wrapped in another function, it returnsnil. Correct approach: make suredefer func() { recover() }()is written directly in the function that may panic. -
Bad code: using a composite literal directly in an
ifcondition without parentheses, e.g.if err == &MyError{Code: 500}. Why: Go's grammar requires composite literals to be parenthesized when they appear in the condition position ofif/for/switch; otherwise the parser treats it as a statement block and it is a syntax error. Correct approach: writeif err == (&MyError{Code: 500}), but it is better to define a variable first and compare against that.
Best Practices
- Prefer sentinel errors: for common, predictable business errors (such as "user not found", "permission denied"), define package-level sentinel variables so callers can use
errors.Isfor precise checks. - Use
errors.Asto extract custom errors: when an error needs to carry extra information (an error code, details), define a custom error type and useerrors.Asat the call site to extract it — rather than relying on string matching. - Avoid abusing
panic: reservepanicfor unrecoverable exceptions (internal logic bugs, nil pointers). Business logic errors should be handled by returningerror. - Give every goroutine its own recover: if a goroutine may panic, it must use
defer/recoverinternally so the whole program does not crash. - Use
errors.Joinfor batch errors: in batch-processing scenarios, when you want to collect all errors instead of stopping at the first one, useerrors.Jointo return a composite error.
Exercises
- Concept question: explain the difference between
errors.Isanderrors.As, and describe when to use each. - Coding exercise: define a custom error type
ValidationErrorwithFieldandMessagefields. Write a functionvalidatethat returns aValidationErrorwhen the input is empty. Inmain, useerrors.Asto extract the error and print its fields. - Advanced/debugging exercise: analyze the following code, point out the problems, and fix them.
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
type DBError struct {
Code int
}
func (e *DBError) Error() string {
return fmt.Sprintf("db error: %d", e.Code)
}
func query(id int) error {
if id == 404 {
return ErrNotFound
}
if id == 500 {
return &DBError{Code: 500}
}
return nil
}
func main() {
err := query(404)
// Problem 1: this cannot correctly check ErrNotFound
if errors.Is(err, &DBError{}) {
fmt.Println("Found DBError")
}
// Problem 2: this cannot correctly check DBError
if errors.Is(err, ErrNotFound) {
fmt.Println("Found NotFound")
}
}
Reference Answers
Reference Answers (click to expand / collapse)
Question 1
errors.Is checks whether the error chain contains an error equal to a target error; it fits checking sentinel errors (globally unique variables). errors.As walks the chain to find the first error assignable to the target pointer type; it fits extracting specific fields from a custom error.
Question 2
package main
import (
"errors"
"fmt"
)
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error: field=%s, msg=%s", e.Field, e.Message)
}
func validate(name string) error {
if name == "" {
return &ValidationError{Field: "name", Message: "cannot be empty"}
}
return nil
}
func main() {
err := validate("")
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("Field: %s, Message: %s\n", ve.Field, ve.Message)
}
}
Question 3
Problem analysis:
1. errors.Is(err, &DBError{}) cannot match, because &DBError{} is a freshly created pointer that is not equal to the pointer returned by query.
2. errors.Is(err, ErrNotFound) should match ErrNotFound: here err is the ErrNotFound returned by query(404), so that line does match. The logical error the exercise wants fixed is using errors.Is to check a custom error type.
Fixed code:
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
type DBError struct {
Code int
}
func (e *DBError) Error() string {
return fmt.Sprintf("db error: %d", e.Code)
}
func query(id int) error {
if id == 404 {
return ErrNotFound
}
if id == 500 {
return &DBError{Code: 500}
}
return nil
}
func main() {
err := query(404)
// Fix 1: use errors.Is to check the sentinel error
if errors.Is(err, ErrNotFound) {
fmt.Println("Found NotFound")
}
// Fix 2: use errors.As to check the custom error type
var dbErr *DBError
if errors.As(err, &dbErr) {
fmt.Printf("Found DBError: code=%d\n", dbErr.Code)
}
}
Lesson Summary
erroris Go's standard error interface; custom errors implementError() string.- Wrap errors with
fmt.Errorfand%w; useerrors.Isto check sentinel errors anderrors.Asto extract custom errors. errors.Join(Go 1.20+) combines multiple errors.panicandrecoverare only for unrecoverable exceptions;recovermust be called directly in adeferand does not cross goroutines.
Next lesson preview: generics — type parameters, constraints like ~T and comparable, and when to choose generics over interfaces.