Go Deep Dive: Error Handling Patterns

Error handling is where Go diverges most sharply from other languages. No exceptions. No try/catch. Just values — and that's exactly the point.
In Phase 2 you learned the basics: functions return (value, error) and you check if err != nil. That's correct, but it's only the foundation. In production code you need to wrap errors to preserve context, inspect errors to make decisions, and design custom error types that carry structured information.
This deep dive covers all of it.
What You'll Learn
✅ Why Go chose errors-as-values over exceptions
✅ Sentinel errors and when to use them
✅ Custom error types with additional context
✅ Error wrapping with fmt.Errorf("%w", err)
✅ Unwrapping with errors.Is and errors.As
✅ The error chain and how it works internally
✅ Production patterns: logging, HTTP error responses, typed errors
✅ Common mistakes and how to avoid them
Part 1: Why Errors-as-Values?
In Python or Java, you throw an exception and it unwinds the call stack invisibly. The caller at the top of the stack can catch it — or ignore it — and the compiler doesn't care. This leads to undocumented failure modes.
In Go, every error is an explicit return value. The compiler forces you to handle (or explicitly ignore) errors:
// The `error` interface — built into Go, just two words
type error interface {
Error() string
}
// Every function that can fail returns error as the last value
func readFile(path string) ([]byte, error) {
// ...
}
// Caller MUST handle it
data, err := readFile("config.json")
if err != nil {
// handle it: log, wrap, return, or panic — your choice
return fmt.Errorf("loading config: %w", err)
}Benefits:
- Failures are visible at the call site — no hidden control flow
- Static analysis tools can find unhandled errors (
errcheck,staticcheck) - The compiler doesn't enforce handling, but
go vetand linters do
The cost: more lines of code. Go programmers accept this explicitly. The robustness trade-off is worth it.
Part 2: The Three Ways to Create Errors
1. errors.New — Simple, Static Messages
import "errors"
var ErrNotFound = errors.New("not found")
var ErrUnauthorized = errors.New("unauthorized")
func getUser(id int) (*User, error) {
if id <= 0 {
return nil, errors.New("user ID must be positive")
}
// ...
}Use errors.New for one-off errors. Use package-level var declarations for errors you'll check against (errors.Is).
2. fmt.Errorf — Formatted Messages
import "fmt"
func getUser(id int) (*User, error) {
user, err := db.Query("SELECT * FROM users WHERE id = ?", id)
if err != nil {
return nil, fmt.Errorf("querying user %d: %w", id, err)
}
if user == nil {
return nil, fmt.Errorf("user %d: %w", id, ErrNotFound)
}
return user, nil
}The %w verb wraps the original error — preserving the full chain for unwrapping later. This is critical for debugging.
3. Custom Error Types — Structured Data
When the caller needs more than a string, create a type:
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error on field %q: %s", e.Field, e.Message)
}
func validateAge(age int) error {
if age < 0 {
return &ValidationError{Field: "age", Message: "must be non-negative"}
}
if age > 150 {
return &ValidationError{Field: "age", Message: "unrealistic value"}
}
return nil
}The caller can then inspect the type with errors.As to access structured fields like Field.
Part 3: Sentinel Errors
A sentinel error is a package-level error variable used for comparison. It's the Go equivalent of a specific exception class:
package store
import "errors"
// Exported sentinel errors — callers can check against these
var (
ErrNotFound = errors.New("not found")
ErrDuplicate = errors.New("duplicate entry")
ErrUnauthorized = errors.New("unauthorized")
)// Caller uses errors.Is to check
user, err := store.GetUser(id)
if errors.Is(err, store.ErrNotFound) {
// Return 404 to the HTTP client
http.Error(w, "User not found", http.StatusNotFound)
return
}
if err != nil {
// Unknown error — return 500
http.Error(w, "Internal server error", http.StatusInternalServerError)
log.Printf("unexpected error getting user %d: %v", id, err)
return
}Standard library examples you already use:
io.EOF— signals end of streamsql.ErrNoRows— no rows returned from a querycontext.Canceled,context.DeadlineExceeded— context states
When to use sentinel errors
| Situation | Use sentinel? |
|---|---|
| Caller needs to branch on a specific error | ✅ Yes |
| Error is a common, expected condition (not a bug) | ✅ Yes |
| Error is internal to the package | ❌ No |
| Error needs structured data | ❌ No — use custom type instead |
Part 4: Error Wrapping — The Error Chain
Error wrapping is how you add context without losing the original cause.
// Without wrapping — original error is lost
return nil, fmt.Errorf("operation failed") // 😞 caller can't see why
// With wrapping — full chain preserved
return nil, fmt.Errorf("saving user: %w", err) // ✅The %w verb creates an error that wraps err. When you print it, you see the full message. When you unwrap it, you get err back.
Building a chain
func ReadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("ReadConfig: reading file %q: %w", path, err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("ReadConfig: parsing JSON from %q: %w", path, err)
}
return &cfg, nil
}
func StartServer(cfgPath string) error {
cfg, err := ReadConfig(cfgPath)
if err != nil {
return fmt.Errorf("StartServer: %w", err)
}
// ...
return nil
}When StartServer fails, the error message reads:
StartServer: ReadConfig: reading file "config.json": open config.json: no such file or directoryEach layer names itself + wraps the layer below. This is a call stack encoded in the error string — invaluable when debugging production failures.
Part 5: errors.Is and errors.As
errors.Is — Checking the Chain
errors.Is(err, target) traverses the entire error chain looking for a match — not just the outermost error:
// Even though err is wrapped multiple layers deep,
// errors.Is finds ErrNotFound in the chain
err := fmt.Errorf("handler: %w", fmt.Errorf("store: %w", ErrNotFound))
errors.Is(err, ErrNotFound) // true ✅
// Old way — only checks outermost error
err == ErrNotFound // false ❌ — wrapped errors don't equalImplement Is for custom types when you want custom comparison logic:
type HTTPError struct {
Code int
Message string
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.Code, e.Message)
}
// Allow errors.Is to match on status code
func (e *HTTPError) Is(target error) bool {
t, ok := target.(*HTTPError)
if !ok {
return false
}
return t.Code == e.Code
}
// Usage
var ErrNotFoundHTTP = &HTTPError{Code: 404}
err := fmt.Errorf("fetching profile: %w", &HTTPError{Code: 404, Message: "user not found"})
errors.Is(err, ErrNotFoundHTTP) // true ✅ — matched by Codeerrors.As — Extracting Type from the Chain
errors.As(err, &target) finds the first error in the chain that can be assigned to target:
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
func processRequest(input Input) error {
if err := validate(input); err != nil {
return fmt.Errorf("processRequest: %w", err)
}
return nil
}
// At the handler level:
err := processRequest(badInput)
var valErr *ValidationError
if errors.As(err, &valErr) {
// ✅ valErr is populated with the concrete ValidationError
fmt.Printf("Bad field: %s\n", valErr.Field)
http.Error(w, fmt.Sprintf("Invalid %s: %s", valErr.Field, valErr.Message), http.StatusBadRequest)
return
}errors.As searches the chain — even if ValidationError is buried under multiple fmt.Errorf("%w") wrappers.
errors.Is vs errors.As
errors.Is | errors.As | |
|---|---|---|
| Checks | Identity or equality | Type assignability |
| Target | A specific error value | A pointer to an error type |
| Use when | Sentinel errors | Custom error types with fields |
| Example | errors.Is(err, io.EOF) | errors.As(err, &valErr) |
Part 6: Unwrapping Manually
errors.Unwrap(err) returns the wrapped error, or nil if there is none:
wrapped := fmt.Errorf("outer: %w", io.EOF)
inner := errors.Unwrap(wrapped)
fmt.Println(inner) // EOFFor custom types that wrap errors, implement Unwrap():
type AppError struct {
Code string
Message string
Cause error
}
func (e *AppError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Cause)
}
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}
// Implement Unwrap so errors.Is/As work through this type
func (e *AppError) Unwrap() error {
return e.Cause
}Now errors.Is and errors.As can traverse your custom type too.
Part 7: Production Error Patterns
Pattern 1 — HTTP Error Responses
Map errors to HTTP status codes at the boundary (in the handler), not deep in business logic:
type AppError struct {
Code string // machine-readable code
Message string // human-readable message
HTTPCode int // derived HTTP status code
Cause error
}
func (e *AppError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Cause)
}
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}
func (e *AppError) Unwrap() error { return e.Cause }
// Package-level constructors
func NotFound(msg string, cause error) *AppError {
return &AppError{Code: "NOT_FOUND", Message: msg, HTTPCode: 404, Cause: cause}
}
func BadRequest(msg string, cause error) *AppError {
return &AppError{Code: "BAD_REQUEST", Message: msg, HTTPCode: 400, Cause: cause}
}
func Internal(msg string, cause error) *AppError {
return &AppError{Code: "INTERNAL", Message: msg, HTTPCode: 500, Cause: cause}
}// Handler
func getUserHandler(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
user, err := userService.GetByID(r.Context(), id)
if err != nil {
var appErr *AppError
if errors.As(err, &appErr) {
writeError(w, appErr.HTTPCode, appErr.Code, appErr.Message)
} else {
log.Printf("unexpected error: %v", err)
writeError(w, 500, "INTERNAL", "Something went wrong")
}
return
}
writeJSON(w, 200, user)
}Pattern 2 — Error Sentinel + Wrapping in Layers
// store/user.go
package store
var ErrNotFound = errors.New("not found")
func (s *Store) GetUser(ctx context.Context, id int) (*User, error) {
var user User
err := s.db.QueryRowContext(ctx,
"SELECT id, name, email FROM users WHERE id = $1", id,
).Scan(&user.ID, &user.Name, &user.Email)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("user %d: %w", id, ErrNotFound) // wrap sentinel
}
if err != nil {
return nil, fmt.Errorf("querying user %d: %w", id, err)
}
return &user, nil
}// service/user.go
package service
func (s *Service) GetProfile(ctx context.Context, userID int) (*Profile, error) {
user, err := s.store.GetUser(ctx, userID)
if err != nil {
return nil, fmt.Errorf("GetProfile: %w", err) // preserve chain
}
// ...
return profile, nil
}// handler — check sentinel through the chain
profile, err := svc.GetProfile(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
http.Error(w, "Profile not found", 404)
return
}The store.ErrNotFound is visible through multiple layers of wrapping. Business logic stays clean; the handler decides how to respond.
Pattern 3 — Multi-Error (multiple validations)
When you need to collect multiple errors at once:
type MultiError struct {
Errors []error
}
func (m *MultiError) Error() string {
msgs := make([]string, len(m.Errors))
for i, e := range m.Errors {
msgs[i] = e.Error()
}
return strings.Join(msgs, "; ")
}
func (m *MultiError) Unwrap() []error {
return m.Errors // Go 1.20+: return slice for errors.Is/As to walk
}
func validateUser(u User) error {
var errs MultiError
if u.Name == "" {
errs.Errors = append(errs.Errors, &ValidationError{Field: "name", Message: "required"})
}
if u.Email == "" {
errs.Errors = append(errs.Errors, &ValidationError{Field: "email", Message: "required"})
}
if !isValidEmail(u.Email) {
errs.Errors = append(errs.Errors, &ValidationError{Field: "email", Message: "invalid format"})
}
if len(errs.Errors) > 0 {
return &errs
}
return nil
}Go 1.20+ introduced errors.Join for this pattern:
var errs []error
if u.Name == "" {
errs = append(errs, fmt.Errorf("name: required"))
}
if u.Email == "" {
errs = append(errs, fmt.Errorf("email: required"))
}
return errors.Join(errs...) // returns nil if errs is emptyPart 8: The panic and recover — When to Use Them
panic is not exception handling. Use it only for:
- Programming errors (nil pointer dereference, out-of-bounds index)
- Truly unrecoverable situations at startup (missing required env vars, broken required config)
- Initializing package-level state that must succeed
// ✅ Appropriate panic — required env var missing at startup
func mustGetEnv(key string) string {
v := os.Getenv(key)
if v == "" {
panic(fmt.Sprintf("required environment variable %q is not set", key))
}
return v
}
var dbURL = mustGetEnv("DATABASE_URL") // fails fast at startup// ✅ Appropriate recover — in HTTP middleware to survive panics
func panicRecovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if r := recover(); r != nil {
log.Printf("recovered from panic: %v\n%s", r, debug.Stack())
http.Error(w, "Internal server error", 500)
}
}()
next.ServeHTTP(w, r)
})
}// ❌ Inappropriate — don't use panic as flow control
func getUser(id int) *User {
if id <= 0 {
panic("invalid ID") // ❌ return an error instead
}
}Rule: If a failure can plausibly happen in production due to external conditions (bad input, network failure, missing resource), return an error. If it indicates a programming bug that should never happen in correct code, panic is acceptable.
Part 9: Common Mistakes
1. Ignoring Errors
// ❌ Ignoring errors silently
file, _ := os.Open("config.json")
defer file.Close()
// ✅ Handle or explicitly document why you're ignoring
file, err := os.Open("config.json")
if err != nil {
return fmt.Errorf("opening config: %w", err)
}
defer file.Close()Use _ to discard errors only when you've consciously decided the failure doesn't matter and documented why.
2. Wrapping Sentinels Incorrectly
// ❌ Using fmt.Errorf without %w — creates new error, breaks chain
return fmt.Errorf("not found: %v", ErrNotFound) // errors.Is won't work!
// ✅ Use %w to wrap
return fmt.Errorf("user %d: %w", id, ErrNotFound)3. Wrapping Errors Multiple Times
// ❌ Adding context at every single level creates noise
func a() error { return fmt.Errorf("a: %w", b()) }
func b() error { return fmt.Errorf("b: %w", c()) }
func c() error { return fmt.Errorf("c: %w", d()) }
func d() error { return errors.New("original") }
// Result: "a: b: c: original" — too much nestingWrap when you're crossing a meaningful boundary (package, service, I/O operation) — not at every function call.
4. Comparing Errors with ==
// ❌ Direct comparison breaks with wrapping
if err == io.EOF { ... } // fails if err is wrapped
// ✅ Always use errors.Is
if errors.Is(err, io.EOF) { ... } // traverses the chain5. Returning Generic Errors from Public APIs
// ❌ Caller can't distinguish cases
func (s *Store) GetUser(id int) (*User, error) {
return nil, errors.New("error") // what kind of error?
}
// ✅ Return sentinel or typed errors from public APIs
func (s *Store) GetUser(id int) (*User, error) {
// ...
return nil, fmt.Errorf("user %d: %w", id, ErrNotFound)
}Summary and Key Takeaways
Creating errors:
✅ errors.New("msg") for simple, static error values
✅ fmt.Errorf("context: %w", err) to add context and wrap
✅ Custom types with Error() string when you need structured data
Checking errors:
✅ errors.Is(err, target) — check against a specific error through the chain
✅ errors.As(err, &typedErr) — extract a specific error type from the chain
✅ Never use == to compare errors — wrapping breaks it
Wrapping discipline:
✅ Wrap at meaningful boundaries: package edges, I/O, external calls
✅ Name the context: fmt.Errorf("FunctionName: doing X: %w", err)
✅ Implement Unwrap() error in custom types so the chain is traversable
Production:
✅ Define sentinel errors in packages for expected failure modes
✅ Use AppError or similar at HTTP boundaries to map errors to status codes
✅ Use errors.Join (Go 1.20+) or MultiError for collecting multiple failures
✅ Use panic only for programming bugs, never for expected runtime failures
What's Next?
Continue the Go deep dives:
- Deep Dive: Concurrency with Goroutines & Channels — advanced patterns, fan-out/fan-in, pipelines
- Deep Dive: Interfaces & Polymorphism — empty interface, type assertions, interface design
- Deep Dive: Testing in Go — testify, mocks, integration tests
- Deep Dive: Context, HTTP & Building APIs — middleware, real REST APIs
Go companion posts:
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.