Error Handling in Go: Idiomatic Patterns for Clean Code

by Didin J. on Nov 26, 2025 Error Handling in Go: Idiomatic Patterns for Clean Code

Learn idiomatic Go error handling with sentinel errors, wrapping, custom types, API mapping, and concurrency patterns for clean, robust, and maintainable code.

Error handling is one of the most important parts of writing reliable Go applications. Unlike many languages that use exceptions, Go takes a simple, explicit, and predictable approach: functions return errors as normal values. This design encourages developers to think carefully about failure cases, write cleaner control flow, and avoid hidden surprises.

Idiomatic error handling in Go is not just about checking if err != nil repeatedly. It’s about structuring your code so that:

  • Errors are clear and meaningful

  • Failures are easy to trace

  • Business logic remains readable

  • You avoid unnecessary panics or deep nesting

In this tutorial, you’ll learn the foundational concepts and modern techniques used by experienced Go developers, including sentinel errors, wrapping, custom error types, panic/recover patterns, and real-world examples. By the end, you’ll be able to design error flows that are clean, expressive, and easy to maintain—no matter how large your project grows.


Understanding Errors in Go

Go’s error handling model is intentionally simple. Instead of exceptions, Go represents errors using the built-in error type—an interface with a single method:

type error interface {
    Error() string
}

This means any type that implements the Error() method can be treated as an error. Because errors are regular values, they’re easy to pass around, compare, wrap, or return.

1. Creating Basic Errors

The simplest way to create an error is with errors.New:

import "errors"

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

Or with fmt.Errorf when you need formatted text:

err := fmt.Errorf("failed to load user: %s", username)

2. Returning Errors from Functions

In Go, functions often return two values: the result and an error.
Example:

func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

Calling the function:

result, err := divide(10, 2)
if err != nil {
    log.Fatal(err)
}
fmt.Println(result)

This explicit approach makes error handling predictable and avoids surprises that exceptions might cause.

3. Zero Value Convention

When an error occurs, the result value should be the zero value of its type.
Examples:

  • For int → return 0

  • For pointer → return nil

  • For slice → return nil

This ensures callers don’t accidentally use invalid results.

4. Why Go Avoids Exceptions

Go intentionally avoids try/catch because:

  • Exceptions hide control flow

  • They encourage “let it crash” patterns on expected errors

  • They complicate reasoning about the function behavior

Instead, Go’s philosophy is:
👉 Return errors as part of the function signature so the caller decides what to do.

5. Standard Library Error Values

Go’s standard library uses predefined errors (sentinel errors) such as:

if errors.Is(err, io.EOF) {
    // end of file reached
}

This foundational understanding prepares you for Go’s more advanced patterns like wrapping, custom error types, and typed business logic.


Returning Errors Explicitly (Go’s Philosophy)

One of Go’s core design choices is to make error handling explicit. Instead of throwing exceptions, Go encourages developers to return errors as part of the normal function result. This leads to code that is predictable, readable, and easy to reason about.

Explicit error returns form the foundation of Go’s philosophy:
👉 Error handling is part of your program’s logic—not an afterthought.

1. The Multi-Return Pattern

Most Go functions return two values:

  1. The expected result

  2. An error

Example:

func readConfig(path string) ([]byte, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("read config: %w", err)
    }
    return data, nil
}

Calling this function:

config, err := readConfig("config.json")
if err != nil {
    log.Println("failed:", err)
    return
}
fmt.Println("config loaded")

This pattern:

  • Makes error sources clear

  • Keeps the control flow transparent

  • Allows callers to handle errors based on context

2. Early Returns (Fail Fast)

Go developers avoid deeply nested if statements by returning early when an error occurs:

Not idiomatic

if err == nil {
    if value > 0 {
        // logic...
    }
}

Idiomatic

if err != nil {
    return nil, err
}
if value <= 0 {
    return nil, errors.New("value must be positive")
}

This keeps your code flat, readable, and easy to scan.

3. Handling vs Returning Errors

A common beginner mistake is handling errors too early.
Ask yourself:

“Should this function fix the error or simply return it?”

Return the error if:

  • The function cannot decide the best way to recover

  • The caller has more context to decide what to do

Handle/log the error if:

  • The function can resolve the situation

  • Logging is specifically required there

  • It's a top-level handler (e.g., HTTP middleware, main loop)

Example of correct handling:

func startServer() {
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatal("server crashed:", err)
    }
}

4. Errors Are Values

Since errors are values, you can:

  • Wrap them

  • Compare them

  • Pass them to other functions

  • Store them in variables

Example:

var ErrUnauthorized = errors.New("unauthorized access")

This allows powerful patterns we'll explore in later sections.

5. Don’t Panic on Expected Errors

Using panic for normal errors, breaks Go’s design philosophy.
Only use panic when something is truly unrecoverable (covered later in Section 9).

Not idiomatic

if err != nil {
    panic(err)
}

Unless you’re sure the error means the system is in a bad state, returning the error is almost always correct.

func loadUser(id int64) (*User, error) {
    user, err := db.GetUser(id)
    if err != nil {
        return nil, fmt.Errorf("load user %d: %w", id, err)
    }
    if user.Disabled {
        return nil, ErrUserDisabled
    }
    return user, nil
}

This function:

  • Returns early

  • Wraps the error context

  • Leaves decision handling to the caller

  • Remains readable and clean


Sentinel Errors

Sentinel errors are predefined, immutable error values that represent specific failure conditions. They allow you to compare errors directly using == or errors.Is, giving you precise control over how to respond to particular error types.

Common examples from the Go standard library include:

  • io.EOF

  • sql.ErrNoRows

  • os.ErrNotExist

  • http.ErrServerClosed

These errors act as signals—hence the name “sentinel.”

1. Defining Sentinel Errors

You create a sentinel error using errors.New:

var ErrNotFound = errors.New("not found")
var ErrUnauthorized = errors.New("unauthorized")

They are usually declared as var at the package level, so all callers can use and compare them.

2. Using Sentinel Errors in Code

A function can return a sentinel error to indicate a known or expected failure:

func findUser(id int) (*User, error) {
    if id == 0 {
        return nil, ErrNotFound
    }
    // ...
    return &User{}, nil
}

Caller:

user, err := findUser(0)
if errors.Is(err, ErrNotFound) {
    fmt.Println("user not found")
    return
}

3. Comparisons Before Go 1.13

Historically, developers used == to check sentinel errors:

if err == ErrNotFound {
    // handle error
}

This is still valid, but only when:

  • The error is not wrapped

  • You explicitly want to check identity

Since Go 1.13, errors.Is is preferred because it works with wrapped errors.

4. Sentinel Errors + Wrapping

When you wrap an error with %w, you can still detect a sentinel error:

func getUser(id int) (*User, error) {
    if id == 0 {
        return nil, fmt.Errorf("lookup: %w", ErrNotFound)
    }
    return &User{}, nil
}

_, err := getUser(0)
if errors.Is(err, ErrNotFound) {
    fmt.Println("not found!")
}

errors.Is unwraps the entire error chain to find the sentinel value.

5. Pros and Cons of Sentinel Errors

Pros

  • Simple to define and use

  • Easy comparisons

  • Good for common, predictable failures

  • Ideal for shared APIs and libraries

Cons

  • Too many sentinel errors lead to clutter

  • Harder to extend with dynamic information

  • Error messages are fixed (string can't vary)

If you need error context (e.g., item ID, username), sentinel errors become limiting. In those cases, prefer custom error types (covered in Section 6).

6. When Not to Use Sentinel Errors

Avoid sentinel errors when:

  • You need rich context (user ID, file path, details)

  • You need error categories instead of exact matches

  • The caller must inspect the error fields

  • The error message needs to vary

In those situations, using wrapped errors or typed errors is more idiomatic.

7. When Sentinel Errors Shine

Sentinel errors work best when:

  • You have a small number of fixed, universal failure states

  • The caller needs explicit behavior for a specific condition

  • Your package exposes a stable API with well-known outcomes

For example, the Go standard library itself heavily uses sentinel errors because they provide predictable, stable error signals for developers.


Error Wrapping

Error wrapping is one of the most powerful features in Go’s modern error-handling model. Introduced in Go 1.13, wrapping allows developers to add context to an error while preserving the original cause. This makes debugging clearer and enables callers to inspect the underlying error without losing information.

The key concept is:
👉 Wrap errors when passing them up the call stack so the caller knows both what happened and where it happened.

1. Why Wrap Errors?

Wrapping errors helps you answer two questions:

  1. What happened?
    Example: “user not found”

  2. Where did it happen?
    Example: “while updating profile”, “while querying DB”, “while reading file”

Without wrapping, you lose the source context, and debugging becomes painful.

2. Wrapping with fmt.Errorf and %w

To wrap an error, use fmt.Errorf with the %w verb:

return fmt.Errorf("read config: %w", err)

This adds context but keeps err in the chain.

Example:

func loadConfig() error {
    data, err := os.ReadFile("config.json")
    if err != nil {
        return fmt.Errorf("load config: %w", err)
    }
    _ = data
    return nil
}

Caller:

if err := loadConfig(); err != nil {
    fmt.Println(err)
}

Output:

load config: open config.json: no such file or directory

3. Unwrapping Errors

Go provides three main tools to inspect wrapped errors:

1. errors.Unwrap

Returns the next error in the chain.

inner := errors.Unwrap(err)

2. errors.Is

Checks whether an error (or anything it wraps) matches a target error:

if errors.Is(err, os.ErrNotExist) {
    // file not found
}

This works even with wrapping.

3. errors.As

Extracts a specific error type from the chain:

var pathErr *fs.PathError
if errors.As(err, &pathErr) {
    fmt.Println("problem with file:", pathErr.Path)
}

Use errors.As to work with typed errors, not just values.

4. Example: Wrapping in a Real-World Flow

func getUser(id int) (*User, error) {
    user, err := db.QueryUser(id)
    if err != nil {
        return nil, fmt.Errorf("getUser: %w", err)
    }
    return user, nil
}

Caller:

u, err := getUser(10)
if err != nil {
    if errors.Is(err, sql.ErrNoRows) {
        return nil, ErrUserNotFound
    }
    return nil, err
}

Wrapping allows the caller to distinguish:

  • DB-level errors (like sql.ErrNoRows)

  • Higher-level business errors (ErrUserNotFound)

  • Unexpected errors

5. Wrapping Multiple Layers Deep

Go handles deep wrapping chains automatically:

getUser: query DB: connect: dial tcp: connection refused

Each layer adds context, and errors.Is still works.

6. When to Wrap Errors

Wrap the error if:

  • You’re adding context from a lower-level call

  • You’re passing the error up to a higher layer

  • You want to preserve the underlying cause

  • You want to avoid deeply ambiguous messages like “failed to initialize”

Do NOT wrap when:

  • You want to replace the error entirely

  • You’re converting to a typed business error

  • You are logging the error at the same layer
    (Wrapping + logging repeatedly = noisy logs)

7. Before vs After Go 1.13

Before Go 1.13

Libraries used custom wrappers like:

  • pkg/errors

  • Stacked errors

  • Manual annotation

After Go 1.13

  • Native support using %w

  • Standard errors package handles chains

  • No external dependency required

Modern Go strongly prefers the built-in approach.

8. Best Practices for Wrapping

  • Wrap only when adding new context

  • Avoid wrapping the same error multiple times in a loop

  • Keep messages short (the chain will provide detail)

  • Avoid wrapping at the top level (just log instead)

  • Prefer %w over %v for wrapped errors

Wrapping lays the foundation for more advanced patterns, especially typed errors, which we’ll explore next.


Custom Error Types

While sentinel errors and wrapped errors cover many cases, sometimes you need richer information—fields, metadata, categories, or structured details. This is where custom error types shine.

A custom error type is any struct that implements the Error() method. This allows you to attach context such as IDs, paths, status codes, or other data that the caller may need.

Idiomatic Go code often uses custom error types for business logic, validation, and categorized domain errors.

1. Creating a Custom Error Type

A basic custom error type looks like this:

type NotFoundError struct {
    Resource string
    ID       int
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s with ID %d not found", e.Resource, e.ID)
}

Usage:

return nil, &NotFoundError{Resource: "User", ID: 42}

This error now carries structured context.

2. Using errors.As With Custom Errors

To check if an error is of a certain type—even if wrapped—use errors.As:

var nfErr *NotFoundError
if errors.As(err, &nfErr) {
    fmt.Println("missing:", nfErr.Resource, nfErr.ID)
}

This pattern is essential for advanced domain logic.

3. Adding Categories to Your Errors

Sometimes you don’t need specific fields—just categories.
You can create simple typed error enums:

type ErrorCode int

const (
    ErrCodeInvalidInput ErrorCode = iota
    both 
    ErrCodeUnauthorized
)

type AppError struct {
    Code    ErrorCode
    Message string
}

func (e *AppError) Error() string {
    return e.Message
}

Usage:

return nil, &AppError{
    Code:    ErrCodeInvalidInput,
    Message: "email is invalid",
}

Then you can route behavior based on Code.

4. Embedding Errors to Preserve Cause

You can embed another error to maintain a chain:

type ValidationError struct {
    Field string
    Err   error
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on %s: %v", e.Field, e.Err)
}

Caller:

if errors.Is(err, ErrRequired) {
    // handle missing required field
}

This combines typed errors with wrapped errors.

5. Custom Errors for HTTP APIs

A common pattern in Go APIs is to map custom errors to HTTP status codes:

type HTTPError struct {
    StatusCode int
    Err        error
}

func (e *HTTPError) Error() string {
    return e.Err.Error()
}

Usage in handlers:

func GetUserHandler(w http.ResponseWriter, r *http.Request) error {
    user, err := findUser(1)
    if err != nil {
        return &HTTPError{StatusCode: http.StatusNotFound, Err: err}
    }
    return json.NewEncoder(w).Encode(user)
}

Your middleware can later map that to:

var httpErr *HTTPError
if errors.As(err, &httpErr) {
    http.Error(w, httpErr.Error(), httpErr.StatusCode)
    return
}

This is a clean and scalable pattern.

6. When to Use Custom Error Types

Use custom errors when you need:

✔ Extra context (IDs, paths, fields, metadata)
✔ Structured data for logic decisions
✔ Domain-specific categories
✔ Better debugging information
✔ Error-to-HTTP or error-to-gRPC mapping
✔ Validation error groups

Avoid custom errors when:

  • A sentinel error is enough

  • You are only passing through context (wrapping is simpler)

7. Example: Domain-Specific Custom Error

type InsufficientBalanceError struct {
    Required float64
    Current  float64
}

func (e *InsufficientBalanceError) Error() string {
    return fmt.Sprintf(
        "insufficient balance: need %.2f but have %.2f",
        e.Required, e.Current,
    )
}

Usage:

if account.Balance < price {
    return &InsufficientBalanceError{
        Required: price,
        Current:  account.Balance,
    }
}

Caller:

var balErr *InsufficientBalanceError
if errors.As(err, &balErr) {
    fmt.Println("Top up at least:", balErr.Required-balErr.Current)
}

This is far more expressive than a plain string error.

8. Best Practices for Custom Error Types

  • Keep error structures small and focused

  • Use pointers (*MyError) to allow errors.As detection

  • Don’t log inside custom error types

  • Don’t export struct fields unless callers need them

  • Include only meaningful, structured context

  • Avoid mixing custom types with panic/recover


Error Handling Strategies

Now that you understand sentinel errors, wrapping, and custom types, it’s time to look at how to apply them effectively in real Go codebases. Error-handling strategy is crucial because it determines how readable, maintainable, and debuggable your application becomes.

Go developers commonly use a set of idiomatic patterns to keep error handling clean and intentional.

1. Fail Fast (Early Returns)

One of the most widely used strategies is returning early when an error occurs. Avoid deeply nested blocks:

Not idiomatic:

if err == nil {
    if data != nil {
        if isValid(data) {
            // logic
        }
    }
}

Idiomatic:

if err != nil {
    return nil, err
}
if data == nil {
    return nil, ErrNoData
}
if !isValid(data) {
    return nil, ErrInvalidData
}

The code remains flat, simple, and readable.

2. Handle Errors Only When You Can Do Something Useful

A common beginner mistake is handling errors too early—especially logging everywhere.

Ask yourself:

👉 Should this function handle the error or simply return it?

General rule:

  • Return the error when the caller has more context.

  • Handle/log the error at the top of the call stack (e.g., in main, middleware, CLI entry point).

Example:

func loadUser(id int) (*User, error) {
    user, err := db.GetUser(id)
    if err != nil {
        return nil, fmt.Errorf("load user: %w", err)
    }
    return user, nil
}

The handler decides what to do:

user, err := loadUser(10)
if err != nil {
    log.Println("failed to load user:", err)
    return
}

3. Avoid Double Logging

One of the most common issues in Go projects is logging the same error multiple times:

❌ Logging inside a helper function
❌ Logging again in the caller
❌ Logging again in the HTTP middleware

This leads to noisy logs like:

loadUser: query DB: record not found
failed to load user: loadUser: query DB: record not found
500 internal server error: loadUser: query DB: record not found

Rule of thumb:
👉 Log once, ideally at the top.

4. Avoid Panics in Normal Flow

Panics should never be used for expected errors. They interrupt control flow and make the code harder to reason about.

❌ Bad:

if err != nil {
    panic("file missing")
}

Panics are appropriate only when:

  • The system is in a truly unrecoverable state

  • Initialization fails (e.g., no DB connection and the app can’t run)

  • You're handling programmer errors, not user errors

Everything else should be handled via returned errors.

5. Use Helper Functions to Reduce Repetition

Repeated patterns can be extracted:

func readFile(path string) ([]byte, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, wrap(err, "read file", "path", path)
    }
    return data, nil
}

Or using a reusable error wrapper:

func wrap(err error, msg string) error {
    if err == nil {
        return nil
    }
    return fmt.Errorf("%s: %w", msg, err)
}

6. Keep Error Messages Clear and Consistent

Good messages:

  • They are short but descriptive

  • Include context (“while loading config”)

  • Avoid blame (“you passed the wrong input”)

  • Use lowercase and no punctuation (Go style)

Example:

connect to db: timeout
read config: open config.json: no such file or directory

7. Narrow Error Scope with Higher-Level Abstractions

Instead of leaking internal errors upward, map them to domain-level errors:

Low-level:

return nil, sql.ErrNoRows

Mid-level:

if errors.Is(err, sql.ErrNoRows) {
    return nil, ErrUserNotFound
}
return nil, fmt.Errorf("get user: %w", err)

High-level:

user, err := service.LoadUser(10)
if errors.Is(err, ErrUserNotFound) {
    return respond404(w)
}

Each layer adds meaning appropriate to its level.

8. Group Errors in Concurrent Code

When running tasks in goroutines, you need a coordinated way to propagate errors.

Go’s errgroup simplifies this:

g, ctx := errgroup.WithContext(context.Background())

g.Go(func() error {
    return fetchA()
})

g.Go(func() error {
    return fetchB()
})

if err := g.Wait(); err != nil {
    return err
}

The first error stops the group and cancels all other goroutines.

9. Avoid Ignoring Errors

The blank identifier _ should never be used to ignore errors unless truly safe:

❌ Dangerous:

_ = json.Unmarshal(data, &obj)

If you must ignore an error, add a comment explaining why:

_ = session.Close() // best-effort cleanup

10. Use Sentinel Errors Sparingly

Use them to signal specific known cases—not for every error scenario. Too many sentinel errors lead to clutter and confusion.

Better alternatives:

  • Wrapped errors with context

  • Custom error types

  • Domain-level error codes

11. Summary of Idiomatic Strategies

  • Fail fast, avoid nesting

  • Only handle errors where action can be taken

  • Don’t log multiple times

  • Don’t panic about expected problems

  • Use helper functions to keep code clean

  • Wrap errors when adding context

  • Use errors.Is and errors.As for comparisons

  • Avoid ignoring errors

  • Keep error messages simple and consistent


Logging Errors Correctly

Logging is a crucial part of error handling in Go applications, but it’s often misused. Many beginner and intermediate Go codebases suffer from “log spam,” duplicated logs, missing context, or logging at the wrong level. Proper logging ensures that errors are traceable, meaningful, and actionable—especially in production environments.

This section covers the idiomatic way to log errors in Go so your logs stay clean and useful.

1. Log Only Where It Makes Sense

The most important rule:

👉 Don’t log an error more than once.

A common anti-pattern:

func readConfig() ([]byte, error) {
    data, err := os.ReadFile("config.json")
    if err != nil {
        log.Println("readConfig error:", err) // ❌ Don't log here
        return nil, err
    }
    return data, nil
}

func main() {
    _, err := readConfig()
    if err != nil {
        log.Fatal("failed to start:", err) // ❌ Logged again
    }
}

This leads to confusing, duplicated logs.

Rule of thumb:

  • Low-level functions → return errors

  • Entry points (main, HTTP middleware, worker loops) → log errors once

2. Avoid Logging After Wrapping

When you wrap errors with context:

return fmt.Errorf("load user: %w", err)

Logging should happen later, when the wrapped chain is complete.

Otherwise, logs become noisy and inconsistent.

3. Log Errors with Context

Good logs always provide context. For example:

❌ Bad:

error reading file

✅ Good:

error reading file: path=config.json: open config.json: no such file or directory

Context should include:

  • What operation failed

  • Important parameters (path, ID, user email, etc.)

  • The underlying wrapped error

4. Use Structured Logging in Production

Plain log.Println works, but structured logging helps parsing, filtering, and real-time observability.

Popular structured logging libraries:

  • Zap (Uber) → fastest

  • zerolog → zero allocation

  • logrus → familiar API (but slower)

Example using Zap:

logger, _ := zap.NewProduction()
defer logger.Sync()

if err != nil {
    logger.Error("failed to process order",
        zap.Int("order_id", id),
        zap.Error(err),
    )
}

Structured logs help tools like ELK, Loki, or Datadog classify and search efficiently.

5. Use Log Levels Correctly

Choose the correct log level:

Level When to Use
DEBUG Developer info, fine-grained detail
INFO Normal app operation
WARN Unexpected but recoverable situations
ERROR Action needed—operation failed
FATAL The app must stop immediately

Example:

  • Missing optional config → WARN

  • User provides invalid input → INFO or ERROR (depending on context)

  • DB connection lost → ERROR

  • Config file missing on startup → FATAL

6. Avoid Logging Inside Libraries

Libraries should never log.
Instead, return structured errors or typed errors.

Your application layer decides:

  • Where to log

  • What to log

  • How to log

This keeps libraries reusable and clean.

7. Never Log Sensitive Data

Avoid logging:

  • Passwords

  • Tokens / API keys

  • User personal data (email, phone, address)

  • Raw request bodies

If needed, sanitize:

logger.Error("login failed", zap.String("username", sanitize(user)))

8. Ensure Logs Are Machine and Human-Friendly

Good logs:

  • They are short but meaningful

  • Include context keys

  • Don’t include stack traces unless necessary

  • Use consistent key names (e.g., user_id instead of userId)

Example:

ERROR process payment: order_id=123 amount=49.90 err="insufficient funds"

9. Avoid Logging in Hot Paths

Functions called many times per second (middleware, validation, loops) should not log on common failures—only in exceptional cases. Too many logs may cause:

  • Increased latency

  • Disk usage spikes

  • Harder debugging

  • Log rate limits triggering

Instead, return errors to be logged once at a higher level.

10. Summary of Logging Best Practices

  • Log once, not everywhere

  • Add meaningful context

  • Use structured logs in production

  • Don’t log inside libraries

  • Don’t log sensitive data

  • Use proper log levels

  • Avoid logs in hot or tight loops

  • Log errors only after they’re fully wrapped


Panics and Recover

Go’s error-handling model encourages returning errors explicitly—but sometimes failures are so severe or unexpected that normal error returns don’t make sense. That’s where panic and recover come in.

However, panic is not Go’s exception system. It’s a mechanism reserved for truly unrecoverable conditions. Used improperly, panic can destabilize your application and make debugging harder.

This section explains when to use panic, how to recover safely, and how to avoid common misuse.

1. What Is a Panic?

A panic is Go’s way of signaling that the program has encountered a state it cannot safely continue from.

Triggers include:

  • Calling panic() directly

  • Runtime errors such as:

    • nil pointer dereference

    • out-of-bounds slice access

    • type assertions that fail without “comma ok”

When a panic occurs:

  1. The current function stops executing

  2. Deferred functions run

  3. The panic propagates up the call stack

  4. If unhandled, the program crashes

2. When Not to Use Panic

Go beginners often use panic like an exception system—this is wrong.

❌ Do not use panic for:

  • Normal, expected errors

  • Input validation

  • Missing files

  • Invalid JSON

  • Database query failures

  • Network timeouts

All of these should be handled with normal error returns.

3. When Panic Is Appropriate

Panics are acceptable when the program cannot reasonably continue:

a. Programmer Errors

Examples:

var arr []int
fmt.Println(arr[10]) // runtime panic

b. Truly Unrecoverable State

Example: your HTTP server fails to bind to a port in main.

func main() {
    if err := loadCriticalConfig(); err != nil {
        panic("failed to load config: " + err.Error())
    }
}

c. Invariants or internal logic must never fail

Example: a switch statement where every case should be impossible:

switch state {
case StateReady, StateRunning, StateStopped:
    // valid states
default:
    panic(fmt.Sprintf("unknown state: %v", state))
}

d. Tests

Panics can be useful in tests when unexpected states occur.

4. What Is Recover?

recover() allows a deferred function to catch a panic and stop it from crashing the program.

But recover only works inside deferred functions:

func safeRun() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recovered from panic:", r)
        }
    }()
    risky()
}

5. Using Recover Carefully

Recover can prevent your application from crashing—but misuse can hide bugs.

❌ Anti-pattern:

func risky() {
    defer recover() // ❌ recover() return value ignored
    panic("boom")
}

Correct usage:

func riskyWrapper() (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic occurred: %v", r)
        }
    }()
    risky()
    return nil
}

6. Panic in HTTP Servers

Panic inside an HTTP handler will crash the entire server unless recovered.

Thus, a panic-recover middleware is idiomatic:

func RecoverMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                log.Println("panic in handler:", rec)
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

This keeps the server alive while reporting the problem.

7. Don’t Use Recover to Hide Real Problems

Recovering all panics and continuing blindly can lead to:

  • corrupted state

  • inconsistent memory

  • partial updates

  • hidden bugs

  • unpredictable behavior

Recover only where isolation is clear (e.g., HTTP handler, goroutine wrapper).

8. Panics in Goroutines

Panics inside goroutines do not propagate upward.

Example:

go func() {
    panic("boom") // program may crash silently
}()

Solution: wrap goroutines with a recovery function:

func safeGo(fn func()) {
    go func() {
        defer func() {
            if r := recover(); r != nil {
                log.Println("goroutine panic:", r)
            }
        }()
        fn()
    }()
}

Now all goroutines have safety guards.

9. Summary of Panic/Recover Best Practices

  • Don’t use panic for expected errors

  • Use panic only for:

    • programmer errors

    • invariant violations

    • unrecoverable initialization failures

  • Always wrap panic recovery inside defer

  • Use recovery at boundaries (HTTP handlers, goroutines)

  • Never recover internal library panics unless there is a clear strategy

  • Don’t swallow panics silently—log them

  • Convert panic to error only when appropriate


Error Handling in Goroutines

Goroutines are lightweight and powerful, but they introduce a key challenge:
👉 errors inside goroutines don’t automatically propagate to the parent function.

If a goroutine returns an error or panics, the caller won’t know unless you explicitly communicate it. This section explains clean, idiomatic patterns for handling errors in concurrent Go code.

1. Why Goroutine Errors Are Tricky

Example problem:

go func() {
    err := doWork()
    if err != nil {
        // this error is lost forever!
    }
}()

Because the goroutine runs asynchronously, the parent has no way of knowing the result. This leads to:

  • hidden failures

  • inconsistent state

  • debugging difficulty

So Go developers use channels, shared variables, or errgroup to propagate errors safely.

2. Using Channels to Return Errors

The simplest method is sending errors over a channel.

func doAsync(ch chan error) {
    if err := doWork(); err != nil {
        ch <- err
        return
    }
    ch <- nil
}

Caller:

errCh := make(chan error)
go doAsync(errCh)

if err := <-errCh; err != nil {
    log.Println("async task failed:", err)
}

Works, but gets messy with multiple goroutines.

3. Handling Multiple Goroutines with Channels

Example with three goroutines:

errCh := make(chan error, 3)

for i := 0; i < 3; i++ {
    go func(i int) {
        errCh <- doTask(i)
    }(i)
}

for i := 0; i < 3; i++ {
    if err := <-errCh; err != nil {
        return err
    }
}

Problems:

  • Hard to cancel remaining goroutines

  • Requires manual management

  • Easy to leak goroutines if not careful

This is why most modern Go projects use errgroup.

4. Using errgroup to Simplify Concurrent Error Handling

The errgroup package (from golang.org/x/sync/errgroup) is the idiomatic solution.

It runs multiple goroutines and returns the first error encountered.

g, ctx := errgroup.WithContext(context.Background())

g.Go(func() error {
    return callAPI(ctx)
})

g.Go(func() error {
    return processData(ctx)
})

if err := g.Wait(); err != nil {
    return err
}

Features:

  • Automatically waits for all goroutines

  • Cancels remaining goroutines on first error (via context)

  • Clean and readable

5. Error Propagation with Context Cancellation

errgroup.WithContext provides a ctx that gets canceled on the first error.

Example:

g, ctx := errgroup.WithContext(context.Background())

g.Go(func() error {
    return fetchUser(ctx, 10)
})

g.Go(func() error {
    return fetchOrders(ctx, 10)
})

If either fails:

  • ctx is canceled

  • Other goroutines can stop early using <-ctx.Done()

6. Protecting Goroutines from Panics

Panics inside goroutines can crash your entire program.
Always recover inside concurrent workers:

g.Go(func() (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("goroutine panic: %v", r)
        }
    }()
    return riskyWork()
})

This ensures safe propagation of serious errors.

7. Using Worker Pools with Error Handling

Worker pools often use a channel for tasks and error aggregation.

Example:

tasks := make(chan int)
errCh := make(chan error, 1)

for i := 0; i < 5; i++ {
    go func() {
        for t := range tasks {
            if err := process(t); err != nil {
                errCh <- err
            }
        }
    }()
}

Stop early on error:

select {
case err := <-errCh:
    return err
default:
}

Better yet: wrap in errgroup for simplicity.

8. Common Concurrency Error Mistakes

Ignoring goroutine errors

Forgetting to return results anywhere.

Writing to an error variable from multiple goroutines

Causes race conditions.

Blocking forever by forgetting to close channels

A classic concurrency bug.

Using panic instead of returning errors

Panics crash all workers unless explicitly recovered.

Ignoring context cancellation

Wastes CPU, network calls, or DB connections.

9. Best Practices for Error Handling in Goroutines

  • Use errgroup for most cases

  • Always return errors to the parent

  • Wrap errors with context (fmt.Errorf("task %d: %w", i, err))

  • Recover from panics inside goroutines

  • Use context to stop long-running tasks

  • Avoid global variables for sharing errors

  • Prefer buffered channels when multiple goroutines send errors


Error Handling in APIs

Error handling becomes even more important when building APIs, because errors must be translated into clear, meaningful, and secure responses for clients. Whether you're building a REST API, GraphQL service, or gRPC backend, consistent error handling improves debugging, client experience, and maintainability.

This section teaches you how to convert Go errors into proper HTTP responses, include structured JSON error messages, and avoid leaking internal information.

1. Return Errors, Don’t Write Responses in Business Logic

Your service or repository layer should not write HTTP responses.
Instead, you return errors up to the handler.

Service function:

func (s *UserService) GetUser(id int) (*User, error) {
    user, err := s.repo.FindByID(id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, ErrUserNotFound
        }
        return nil, fmt.Errorf("get user: %w", err)
    }
    return user, nil
}

Handler decides the HTTP response:

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    user, err := h.service.GetUser(10)
    if err != nil {
        h.respondError(w, err)
        return
    }
    json.NewEncoder(w).Encode(user)
}

This clean separation keeps logic pure and testable.

2. Map Domain Errors to HTTP Status Codes

A common pattern is mapping typed or sentinel errors to status codes:

func (h *UserHandler) respondError(w http.ResponseWriter, err error) {
    switch {
    case errors.Is(err, ErrUserNotFound):
        http.Error(w, err.Error(), http.StatusNotFound)
    case errors.Is(err, ErrInvalidInput):
        http.Error(w, err.Error(), http.StatusBadRequest)
    default:
        http.Error(w, "internal server error", http.StatusInternalServerError)
    }
}

This ensures consistent behavior across all handlers.

3. Avoid Leaking Internal Errors to Clients

Don’t return detailed internal messages like:

load user: query DB: connection refused

These messages may reveal:

  • internal structure

  • SQL queries

  • sensitive context

  • deployment details

Instead, return generic messages, and log the detailed ones server-side:

log.Println("load user:", err)
http.Error(w, "user not available", 500)

4. Structured JSON Error Responses (Recommended)

JSON error responses are easier for clients to parse.

Example:

type ErrorResponse struct {
    Error   string `json:"error"`
    Message string `json:"message,omitempty"`
    Field   string `json:"field,omitempty"`
}

Usage:

func writeJSONError(w http.ResponseWriter, status int, message string) {
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(ErrorResponse{Error: message})
}

Handler:

if errors.Is(err, ErrUserNotFound) {
    writeJSONError(w, http.StatusNotFound, "user not found")
    return
}

5. Validation Errors

Validation errors commonly require field-level details:

type ValidationError struct {
    Field string `json:"field"`
    Msg   string `json:"message"`
}

Bundled error response:

type ValidationErrors struct {
    Errors []ValidationError `json:"errors"`
}

Return for invalid input:

writeJSON(w, 400, ValidationErrors{
    Errors: []ValidationError{
        {"email", "must be valid"},
        {"username", "required"},
    },
})

6. Using Error Wrapping in APIs

Wrapping provides excellent logs without exposing details:

return nil, fmt.Errorf("find user %d: %w", id, err)

But the handler should not expose that entire chain:

log.Println("failed:", err)
writeJSONError(w, 500, "internal error")

7. Custom HTTPError Type

Many production apps use a custom type to standardize responses:

type HTTPError struct {
    Status  int
    Message string
    Err     error
}

func (e *HTTPError) Error() string { return e.Err.Error() }

Usage:

return nil, &HTTPError{
    Status:  http.StatusNotFound,
    Message: "user not found",
    Err:     ErrUserNotFound,
}

Middleware:

var httpErr *HTTPError
if errors.As(err, &httpErr) {
    writeJSONError(w, httpErr.Status, httpErr.Message)
    return
}

This allows a single, centralized place to define error responses.

8. Gracefully Handling Panics in HTTP APIs

Use a panic recovery middleware (shown earlier) so your API doesn’t crash:

func RecoverMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                log.Println("panic:", rec)
                writeJSONError(w, 500, "server error")
            }
        }()
        next.ServeHTTP(w, r)
    })
}

This keeps panics from being exposed to clients.

9. gRPC Error Handling Briefly

If using gRPC:

  • Use status.Error and codes.*

  • Convert domain errors to proper gRPC codes

  • Example:

if errors.Is(err, ErrUserNotFound) {
    return nil, status.Error(codes.NotFound, "user not found")
}

This mirrors the HTTP mapping but via protocol-specific error codes.

10. Key Best Practices for API Error Handling

  • Keep business logic separate from HTTP logic

  • Return structured JSON errors

  • Do not expose wrapped/internal error chains

  • Map sentinel or typed errors to HTTP status codes

  • Log internal errors, but respond with safe messages

  • Use recovery middleware to avoid API crashes

  • Standardize errors across your entire API

  • Avoid writing different error formats in different handlers


Real-World Examples

Now that you’ve learned all the idiomatic techniques—sentinel errors, wrapping, typed errors, panic recovery, structured logs, and API mapping—let’s put everything together.
This section covers practical, real-world error-handling scenarios you’ll face in typical Go applications.

We'll go through:

  1. File processing with wrapped + typed errors

  2. Database operations with sentinel mapping

  3. Validation errors in services

  4. A complete REST API flow using all techniques

  5. Concurrency with errgroup in a real scenario

1. File Processing Example (Wrapping + Sentinel Errors)

Scenario:

Load and parse a JSON config file. Produce helpful errors for the caller.

Code:

var ErrConfigNotFound = errors.New("config not found")

func LoadConfig(path string) ([]byte, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        if errors.Is(err, os.ErrNotExist) {
            return nil, ErrConfigNotFound
        }
        return nil, fmt.Errorf("read config %s: %w", path, err)
    }
    return data, nil
}

Caller:

config, err := LoadConfig("config.json")
switch {
case errors.Is(err, ErrConfigNotFound):
    log.Println("config file missing!")
case err != nil:
    log.Println("unexpected config error:", err)
default:
    fmt.Println("config loaded")
}

Highlights:

  • Maps OS-level error → domain error

  • Wraps all other unexpected errors

  • The caller acts based on the error category

2. Database Example (Sentinel → Typed Error Mapping)

Scenario:

User lookup with DB-level sentinel errors mapped to business errors.

var ErrUserNotFound = errors.New("user not found")

func (r *UserRepo) FindByID(id int) (*User, error) {
    user := &User{}
    err := r.db.QueryRow("SELECT id, email FROM users WHERE id=?", id).
        Scan(&user.ID, &user.Email)

    if errors.Is(err, sql.ErrNoRows) {
        return nil, ErrUserNotFound
    }
    if err != nil {
        return nil, fmt.Errorf("query user %d: %w", id, err)
    }
    return user, nil
}

Highlights:

  • Converts low-level sql.ErrNoRows → domain ErrUserNotFound

  • Wraps unexpected DB errors with context

  • User-facing layer won’t accidentally leak SQL errors

3. Validation Example (Custom Typed Errors)

Scenario:

User input validation with field-level details.

type ValidationError struct {
    Field string
    Msg   string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("%s: %s", e.Field, e.Msg)
}

func ValidateUser(u *User) error {
    if u.Email == "" {
        return &ValidationError{Field: "email", Msg: "required"}
    }
    if !strings.Contains(u.Email, "@") {
        return &ValidationError{Field: "email", Msg: "invalid format"}
    }
    return nil
}

Caller:

if err := ValidateUser(user); err != nil {
    var ve *ValidationError
    if errors.As(err, &ve) {
        fmt.Printf("Validation failed on %s: %s\n", ve.Field, ve.Msg)
    }
}

4. Full REST API Flow Example (Complete Integration)

This combines:

  • Domain sentinel errors

  • Wrapping

  • Typed HTTP errors

  • Centralized API error response

  • Logging best practices

4.1 Domain Layer

var ErrUserDisabled = errors.New("user disabled")

func (s *UserService) GetUser(id int) (*User, error) {
    user, err := s.repo.FindByID(id)
    if err != nil {
        return nil, fmt.Errorf("get user: %w", err)
    }
    if user.Disabled {
        return nil, ErrUserDisabled
    }
    return user, nil
}

4.2 HTTPError Wrapper

type HTTPError struct {
    Status  int
    Message string
    Err     error
}

func (e *HTTPError) Error() string {
    return e.Err.Error()
}

4.3 Handler-Level Error Mapping

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) error {
    id := 10
    user, err := h.service.GetUser(id)
    if err != nil {
        switch {
        case errors.Is(err, ErrUserNotFound):
            return &HTTPError{404, "user not found", err}
        case errors.Is(err, ErrUserDisabled):
            return &HTTPError{403, "user disabled", err}
        default:
            return &HTTPError{500, "server error", err}
        }
    }
    return writeJSON(w, 200, user)
}

4.4 Central Error Middleware

func (h *UserHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    err := h.route(w, r)
    if err != nil {
        var httpErr *HTTPError
        if errors.As(err, &httpErr) {
            log.Println("handler error:", httpErr.Err)
            writeJSONError(w, httpErr.Status, httpErr.Message)
            return
        }

        // fallback
        log.Println("unexpected:", err)
        writeJSONError(w, 500, "internal error")
    }
}

This pattern ensures:

  • Business logic → returns typed/sentinel errors

  • Handlers → map to HTTP

  • Middleware → logs once and writes JSON response

5. Concurrency Example (errgroup + Wrapping)

Scenario:

Fetch user profile, orders, and permissions concurrently.

func LoadDashboard(ctx context.Context, userID int) (*Dashboard, error) {
    g, ctx := errgroup.WithContext(ctx)

    var user *User
    var orders []Order
    var perms []string

    g.Go(func() error {
        u, err := fetchUser(ctx, userID)
        if err != nil {
            return fmt.Errorf("fetchUser: %w", err)
        }
        user = u
        return nil
    })

    g.Go(func() error {
        o, err := fetchOrders(ctx, userID)
        if err != nil {
            return fmt.Errorf("fetchOrders: %w", err)
        }
        orders = o
        return nil
    })

    g.Go(func() error {
        p, err := fetchPermissions(ctx, userID)
        if err != nil {
            return fmt.Errorf("fetchPermissions: %w", err)
        }
        perms = p
        return nil
    })

    if err := g.Wait(); err != nil {
        return nil, err
    }

    return &Dashboard{User: user, Orders: orders, Perms: perms}, nil
}

This gives:

  • Clean propagation of the first error

  • Automatic cancellation via ctx

  • Wrapped context for debugging

6. Summary of Real-World Patterns

✔ Map low-level errors to domain-specific sentinel errors
✔ Wrap errors when adding operational context
✔ Use typed errors for validation & structured flows
✔ Convert errors to standardized JSON in APIs
✔ Protect handlers & goroutines using panic recovery
✔ Use errgroup to keep concurrency manageable
✔ Log errors once, at the right level
✔ Never leak internal error chains to clients


Best Practices Summary

This section distills everything you’ve learned into a clean, practical checklist you can apply to any Go project. These are the idiomatic patterns followed by experienced Go developers—not just stylistic preferences, but approaches proven to produce clean, maintainable, and debuggable Go code.

1. Core Principles

1. Errors Are Values

Treat errors as part of your function’s normal output.
No exceptions, no hidden control flow.

2. Return Early (Fail Fast)

Avoid nested if blocks.
Stop as soon as an error occurs.

3. Add Context with Wrapping

Use:

fmt.Errorf("load config: %w", err)

Context helps debugging without exposing details to clients.

4. Log Once, Preferably at the Top

Never log the same error multiple times.
Handlers or main() should handle logging—not deep service layers.

2. Sentinel Errors (Use Sparingly)

✔ Use them when:

  • You need to signal a specific known condition

  • The error message is universal across your package

✘ Avoid them when:

  • You need extra context (IDs, fields, details)

  • The error message must be dynamic

Use errors.Is when comparing sentinel errors.

3. Custom Error Types

Use typed errors when you need:

  • Metadata (field, user ID, amount, etc.)

  • Validation messages

  • Mapping between layers (e.g., to HTTP codes)

Implement:

func (e *MyError) Error() string

Check using errors.As.

4. Wrapping Errors Correctly

✔ Wrap errors when:

  • Adding context from lower-level functions

✘ Do NOT wrap when:

  • Converting to a higher-level domain error

  • Logging at that level

Use %w only once per layer.

5. Panics and Recover

✔ Use panic only for:

  • Programmer errors

  • Impossible states

  • Unrecoverable initialization failures

  • Test shortcuts

✘ Do NOT use panic for:

  • Validation

  • Missing files

  • Database errors

  • Network errors

Always recover in:

  • HTTP middleware

  • Goroutine wrappers

6. Goroutines and Concurrency

✔ Use errgroup for multiple goroutines

Cleaner, cancellable, and easy to propagate errors.

✔ Protect goroutines with deferred recover blocks

Panics in goroutines don’t propagate up.

✔ Use context to cancel long-running tasks

Cancel early when any error occurs.

7. API-Level Error Handling

✔ Map domain errors → HTTP status codes

Avoid leaking internal errors to clients.

✔ Return structured JSON errors

Consistent and easy to parse.

✔ Log detailed errors server-side

Return safe messages to users.

✔ Use a centralized error response middleware

Prevents duplicated logic across handlers.

8. Validation Best Practices

  • Use custom-typed errors for field-specific feedback

  • Aggregate validation errors in a list

  • Don’t return generic messages for validation problems

  • Don’t panic over user mistakes

9. Logging Best Practices

✔ Use structured logging in production

Zap, zerolog, or logrus.

✔ Include contextual fields

e.g., user_id, order_id, path

✔ Avoid logging in libraries

Leave logging to the application boundary.

✔ Don’t leak sensitive data

Mask or sanitize tokens, emails, etc.

10. Clean Code Guidelines

  • Keep error messages short and lowercase

  • Use consistent prefixes: "read config:", "fetch user:"

  • Don’t ignore errors unless intentional (and comment why)

  • Don’t use overly clever error wrapping

  • Keep layers responsible for error mapping (e.g., repo → service → handler)

11. Quick Checklist

Here’s your at-a-glance checklist:

✅ Return errors, don’t panic
✅ Fail fast, avoid nesting
✅ Wrap errors when adding context
❌ Don’t log deep in the call stack
❌ Don’t log multiple times
🔍 Use errors.Is and errors.As
📦 Use typed errors when you need structure
🌐 In APIs, convert domain errors → HTTP codes
💥 Recover from panics at safe boundaries
🔁 Use errgroup for concurrent tasks
🧼 Keep errors clear, consistent, and lowercase
🔒 Don’t leak sensitive error details


Conclusion

Error handling is one of the most important skills in Go development. Unlike many languages that rely on exceptions or magic control flow, Go embraces a simple, explicit, and transparent approach: errors are values. This philosophy results in clearer code, predictable behavior, and fewer hidden surprises—especially in large, production systems.

Throughout this tutorial, you learned how to use every tool Go provides to build robust, idiomatic error flows:

✔ Returning errors explicitly

The foundation of Go’s error model—functions return errors as normal values, giving you complete control over how failures are handled.

✔ Sentinel errors for known conditions

Useful for simple, shared failure states like ErrNotFound.

✔ Wrapping errors with context

Using %w to preserve the underlying cause while adding meaningful context.

✔ Custom error types

Perfect for structured data like field names, validation messages, or domain-specific metadata.

✔ Proper logging practices

Log once, log with context, and avoid noisy or redundant messages.

✔ Safe use of panic and recover

Only for truly unrecoverable failures—never for normal error-handling flow.

✔ Concurrency-safe error handling

Using channels or errgroup to manage errors in goroutines without losing visibility.

✔ Clean, consistent API error responses

Mapping domain errors to HTTP codes, using JSON error structures, and avoiding leakage of sensitive system details.

✔ Real-world patterns

Combining all of the above in file operations, database logic, validation flows, REST APIs, and concurrent workloads.

The Go Philosophy in One Sentence

👉 Handle errors explicitly, consistently, and as close to the source as possible—while keeping your code clean, readable, and predictable.

Mastering these patterns not only makes your code more robust but also aligns you with the practices used by experienced Go engineers across the industry. With these tools, you'll be able to design error flows that scale smoothly—whether you're building microservices, CLIs, APIs, or distributed systems.

You can find the full source code on our GitHub.

That's just the basics. If you need more deep learning about Go/Golang, you can take the following cheap course:

Thanks!