Skip to content

Go Error Handling — Error Interface Error Wrapping and Sentinel Errors Explained

DodaTech Updated 2026-06-28 9 min read

In this tutorial, you will learn about Go Error Handling. We cover key concepts, practical examples, and best practices to help you master this topic.

Go error handling uses the error interface with explicit return values, %w wrapping for error chains, errors.Is and errors.As for inspection, and sentinel errors for predefined failure conditions.

What You'll Learn

  • Creating and returning errors
  • Wrapping errors with %w
  • Inspecting errors with Is and As
  • Sentinel and custom error types

Why It Matters

Explicit error handling is a Go philosophy. Docker wraps errors throughout its codebase. Kubernetes uses structured error types. DodaZIP uses error wrapping for archive handling. Proper errors make debugging production issues possible.

Real-World Use

File operations, network requests, database queries, API calls — all return errors. Error handling isn't optional in Go; it's part of every function's contract.

flowchart LR
    A["Errors"] --> B["error interface"]
    B --> C["fmt.Errorf"]
    C --> D["Wrapping"]
    D --> E["errors.Is/As"]
    A:::current --> B
    style A fill:#2563eb,stroke:#2563eb,color:#fff
    style B fill:#dbeafe,stroke:#2563eb,color:#1e40af
    style C fill:#dbeafe,stroke:#2563eb,color:#1e40af
    style D fill:#dbeafe,stroke:#2563eb,color:#1e40af
    style E fill:#f1f5f9,stroke:#94a3b8,color:#64748b

The Error Interface

type error interface {
    Error() string
}

Any type with an Error() string method satisfies the interface:

type MyError struct {
    Code    int
    Message string
}

func (e *MyError) Error() string {
    return fmt.Sprintf("error %d: %s", e.Code, e.Message)
}

func doSomething() error {
    return &MyError{Code: 404, Message: "not found"}
}

func main() {
    err := doSomething()
    if err != nil {
        fmt.Println(err)  // error 404: not found
    }
}

Creating Errors

// Simple errors
err := errors.New("something went wrong")
err := fmt.Errorf("user %d not found", userID)

// With formatting
err := fmt.Errorf("failed to process file %s: %w", filename, innerErr)

errors.New

var ErrNotFound = errors.New("resource not found")

func GetUser(id int) (*User, error) {
    if id <= 0 {
        return nil, ErrNotFound
    }
    return &User{ID: id}, nil
}

fmt.Errorf

func OpenConfig(path string) (*Config, error) {
    file, err := os.Open(path)
    if err != nil {
        return nil, fmt.Errorf("opening config %s: %w", path, err)
    }
    defer file.Close()
    // ...
}

Error Wrapping with %w

func ReadConfig(path string) ([]byte, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        // Wrap the error with context
        return nil, fmt.Errorf("read config %s: %w", path, err)
    }
    return data, nil
}

func LoadConfig(path string) (*Config, error) {
    data, err := ReadConfig(path)
    if err != nil {
        return nil, fmt.Errorf("load config: %w", err)
    }
    // Parse data...
    return &Config{}, nil
}

func main() {
    _, err := LoadConfig("/nonexistent/config.json")
    if err != nil {
        fmt.Println(err)
        // load config: read config /nonexistent/config.json: open /nonexistent/config.json: no such file or directory
    }
}

Inspecting Errors

errors.Is — check error in chain

var ErrPermission = errors.New("permission denied")

func CheckAccess(user string) error {
    if user != "admin" {
        return fmt.Errorf("access check: %w", ErrPermission)
    }
    return nil
}

func main() {
    err := CheckAccess("alice")
    if errors.Is(err, ErrPermission) {
        fmt.Println("Permission denied!")
    }
}

errors.As — get specific error type

type HTTPError struct {
    StatusCode int
    Message    string
}

func (e *HTTPError) Error() string {
    return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Message)
}

func Fetch(url string) error {
    return &HTTPError{StatusCode: 404, Message: "Not Found"}
}

func main() {
    err := Fetch("/api/users")
    var httpErr *HTTPError
    if errors.As(err, &httpErr) {
        fmt.Printf("HTTP error: %d - %s\n", httpErr.StatusCode, httpErr.Message)
        // HTTP error: 404 - Not Found
    }
}

Sentinel Errors

Predefined errors that signal specific conditions:

var (
    ErrNotFound   = errors.New("not found")
    ErrPermission = errors.New("permission denied")
    ErrTimeout    = errors.New("operation timed out")
    ErrClosed     = errors.New("connection closed")
)

type UserStore struct {
    users map[int]string
}

func (s *UserStore) Get(id int) (string, error) {
    name, ok := s.users[id]
    if !ok {
        return "", fmt.Errorf("user store: %w", ErrNotFound)
    }
    return name, nil
}

func main() {
    store := &UserStore{users: make(map[int]string)}
    _, err := store.Get(42)
    if errors.Is(err, ErrNotFound) {
        fmt.Println("User not found")
    }
}

Custom Error Types

// Structured error with additional context
type ValidationError struct {
    Field   string
    Value   interface{}
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation: %s - %s (got %v)", e.Field, e.Message, e.Value)
}

func ValidateUser(name string, age int) error {
    var errs []error
    if name == "" {
        errs = append(errs, &ValidationError{
            Field: "name", Value: name, Message: "cannot be empty",
        })
    }
    if age < 0 || age > 150 {
        errs = append(errs, &ValidationError{
            Field: "age", Value: age, Message: "must be between 0 and 150",
        })
    }
    if len(errs) > 0 {
        return fmt.Errorf("validation failed: %w", &MultiError{Errors: errs})
    }
    return nil
}

type MultiError struct {
    Errors []error
}

func (m *MultiError) Error() string {
    msgs := make([]string, len(m.Errors))
    for i, err := range m.Errors {
        msgs[i] = err.Error()
    }
    return strings.Join(msgs, "; ")
}

Panic and Recover

func safeDivide(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic recovered: %v", r)
        }
    }()
    return a / b, nil
}

func main() {
    result, err := safeDivide(10, 0)
    if err != nil {
        fmt.Println("Error:", err)  // Error: panic recovered: runtime error: integer divide by zero
    } else {
        fmt.Println("Result:", result)
    }
}

Best Practices

// 1. Always check errors
file, err := os.Open("file.txt")
if err != nil {
    return err  // Or handle it
}
defer file.Close()

// 2. Add context when propagating
if err != nil {
    return fmt.Errorf("processing order %d: %w", orderID, err)
}

// 3. Handle errors once
// Bad — log and return
if err != nil {
    log.Println(err)
    return err  // Logged and returned — double handling
}
// Good — either log or return, not both

// 4. Use sentinel errors for expected failures
var ErrNotFound = errors.New("not found")

// 5. Create custom types for structured errors
type TimeoutError struct {
    Duration time.Duration
}
func (e *TimeoutError) Error() string { return "timeout" }

Common Mistakes

1. Ignoring Errors

// Bad
io.Copy(dst, src)  // Error ignored!

// Good
if _, err := io.Copy(dst, src); err != nil {
    return err
}

2. String Comparison Instead of errors.Is

// Bad — fragile
if err.Error() == "not found" { }

// Good
if errors.Is(err, ErrNotFound) { }

3. Shadowing err Variable

// Bad — shadows outer err
if err := doSomething(); err != nil {
    // err shadows outer variable
}

// Good — explicit
err := doSomething()
if err != nil {
    return err
}

4. Not Wrapping with %w

// Bad — loses error chain
return fmt.Errorf("failed: %v", err)

// Good — preserves chain
return fmt.Errorf("failed: %w", err)

5. Panic for Expected Errors

// Bad — panic for common failures
if err != nil {
    panic(err)
}

// Good — return error
if err != nil {
    return err
}

Practice Questions

1. What is the error interface?

type error interface {
    Error() string
}

Any type implementing Error() string satisfies the error interface.

2. How does %w differ from %v in fmt.Errorf?

%w creates a wrapped error for use with errors.Is and errors.As. %v creates a formatted string without wrapping. Use %w to preserve the error chain.

3. What are sentinel errors?

Predefined error variables signaling specific conditions: var ErrNotFound = errors.New("not found"). Check with errors.Is.

4. When should you use custom error types?

When callers need structured data (status code, field name) or different behavior. Use errors.As to extract the custom type.

Challenge: Implement a Retry function that retries an operation on specific errors, with configurable attempts and backoff.

Solution
package main

import (
    "errors"
    "fmt"
    "time"
)

type RetryableError struct {
    Err error
}

func (e *RetryableError) Error() string {
    return fmt.Sprintf("retryable: %v", e.Err)
}

func (e *RetryableError) Unwrap() error {
    return e.Err
}

func IsRetryable(err error) bool {
    var retryable *RetryableError
    return errors.As(err, &retryable)
}

func Retry(attempts int, sleep time.Duration, fn func() error) error {
    var err error
    for i := 0; i < attempts; i++ {
        err = fn()
        if err == nil {
            return nil
        }
        if !IsRetryable(err) {
            return err
        }
        if i < attempts-1 {
            time.Sleep(sleep)
            sleep *= 2 // Exponential backoff
        }
    }
    return fmt.Errorf("all %d attempts failed: %w", attempts, err)
}

func main() {
    attempt := 0
    err := Retry(3, 100*time.Millisecond, func() error {
        attempt++
        if attempt < 3 {
            return &RetryableError{Err: fmt.Errorf("attempt %d failed", attempt)}
        }
        return nil
    })
    fmt.Println("Result:", err)  // Result: <nil> (succeeded on attempt 3)
}

FAQ

{{< faq question="Should I use panic or return error?" >}} Almost always return error. Panic is for truly exceptional conditions (programmer bugs, unrecoverable states). Normal error conditions should use the error interface. {{< /faq >}}

{{< faq question="What is the difference between errors.Is and errors.As?" >}} Is checks if any error in the chain matches a target error value. As finds the first error in the chain that matches a target type and extracts it. {{< /faq >}}

{{< faq question="How do I add context to an error?" >} Use fmt.Errorf("context: %w", err) to wrap with context. The %w verb preserves the original error for Is/As inspection. Use %v if you don't need the chain. {{< /faq >}}

{{< faq question="Should I define errors in the package or where they're used?" >} Define sentinel errors in the package that produces them. Export them (capital letter) so callers can check with errors.Is. Don't duplicate error definitions. {{< /faq >}}

{{< faq question="What is error wrapping?" >}} Creating an error that contains another error, preserving the original for inspection. errors.Unwrap extracts the inner error. errors.Is and errors.As traverse the chain. {{< /faq >}}

Try It Yourself

package main

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("item not found")

type ItemStore struct {
    items map[string]string
}

func NewItemStore() *ItemStore {
    return &ItemStore{items: make(map[string]string)}
}

func (s *ItemStore) Get(key string) (string, error) {
    val, ok := s.items[key]
    if !ok {
        return "", fmt.Errorf("store.Get(%s): %w", key, ErrNotFound)
    }
    return val, nil
}

func (s *ItemStore) Set(key, value string) {
    s.items[key] = value
}

func main() {
    store := NewItemStore()
    store.Set("greeting", "Hello, World!")

    // Successful lookup
    if val, err := store.Get("greeting"); err != nil {
        fmt.Println("Error:", err)
    } else {
        fmt.Println("Found:", val)
    }

    // Failed lookup
    if _, err := store.Get("missing"); err != nil {
        fmt.Println("Error:", err)
        if errors.Is(err, ErrNotFound) {
            fmt.Println("=> Item not found (sentinel check)")
        }
    }
}

Expected output:

Found: Hello, World!
Error: store.Get(missing): item not found
=> Item not found (sentinel check)

What's Next

Now that you understand error handling, learn about panics and recovery for exceptional situations.

Topic Description Link
Go Panics and Recovery panic, recover, defer {{< ref "14-panics" >}}
Go Packages and Modules go.mod, imports, visibility {{< ref "15-packages" >}}
Rust Error Handling Compare with Rust's Result and Option Rust

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro

Home Browse Go