Mastering Error Handling in Go: Wrap Error, Custom Error, and Best Practices for Real-World Projects

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Go handles errors differently from most languages — no try/catch like Java, no exceptions like Python. Functions return error as an ordinary return value and the caller decides how to handle it. Simple in theory, but as a project grows, the lack of a clear strategy turns debugging into a nightmare.

The last web app project I worked on had 5 developers. In the first week, everyone did their own thing: some used errors.New, others wrapped with fmt.Errorf, others defined their own structs. The result? Tracing a single production error took an entire morning because messages like “something went wrong” told us nothing. Once the team sat down and agreed on a consistent approach, the same type of error took about 10 minutes to track down.

Three Common Error Handling Approaches in Go

Approach 1: Plain errors.New and fmt.Errorf

The simplest approach, suitable for scripts or small internal helpers:

package main

import (
    "errors"
    "fmt"
)

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

func getUserByID(id int) (string, error) {
    if id <= 0 {
        return "", fmt.Errorf("invalid user id: %d", id)
    }
    if id == 999 {
        return "", ErrNotFound
    }
    return "Alice", nil
}

func main() {
    _, err := getUserByID(0)
    if err != nil {
        fmt.Println(err) // invalid user id: 0
    }

    _, err = getUserByID(999)
    if errors.Is(err, ErrNotFound) {
        fmt.Println("User does not exist")
    }
}

Sentinel errors like ErrNotFound are defined at the package level. Callers use errors.Is(err, ErrNotFound) to check — avoid direct == comparison since it fails when the error is wrapped.

Approach 2: Custom Error Type

When you need to attach metadata to an error — HTTP status code, error code, operation name — a custom struct is the better choice:

type AppError struct {
    Code    int
    Message string
    Op      string // operation name
}

func (e *AppError) Error() string {
    return fmt.Sprintf("[%s] %d: %s", e.Op, e.Code, e.Message)
}

func getUserByID(id int) (string, error) {
    if id <= 0 {
        return "", &AppError{
            Code:    400,
            Message: "invalid user id",
            Op:      "getUserByID",
        }
    }
    return "Alice", nil
}

func main() {
    _, err := getUserByID(-1)
    if err != nil {
        var appErr *AppError
        if errors.As(err, &appErr) {
            fmt.Printf("HTTP %d: %s\n", appErr.Code, appErr.Message)
        }
    }
}

errors.As traverses the entire error chain to find a matching type — unlike a direct type assertion that only checks the surface level.

Approach 3: Wrapping Errors with %w

Since Go 1.13, fmt.Errorf supports the %w verb to wrap the original error while preserving the full call stack context:

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

func initApp() error {
    if err := readConfig("/etc/app/config.yaml"); err != nil {
        return fmt.Errorf("initApp: %w", err)
    }
    return nil
}

func main() {
    err := initApp()
    if err != nil {
        fmt.Println(err)
        // initApp: readConfig: open /etc/app/config.yaml: no such file or directory

        if errors.Is(err, os.ErrNotExist) {
            fmt.Println("Config file does not exist")
        }
    }
}

The error message prints the full call path — one line tells you exactly which functions the error passed through.

Pros and Cons Analysis

  • errors.New / plain fmt.Errorf — quick to write, minimal boilerplate. But callers can’t distinguish between error types: no way to tell if it’s a 404, 401, or a database timeout. Conditional handling is nearly impossible. Reasonable for small scripts or internal helpers.
  • Custom Error Type — attach metadata (HTTP status, error code, operation name) to errors. Callers use errors.As to catch the right type and handle each case. The cost is ~10–15 lines of boilerplate per error type — worth it for the API layer and business logic.
  • Wrap Error with %w — automatically builds a context chain like initApp: readConfig: open /etc/app/config.yaml: no such file. One log line traces the entire call path. The chain can get long with deep nesting, but that’s a trade-off worth accepting.

These three approaches aren’t mutually exclusive — real-world projects typically combine all three, each playing its own role.

Choosing the Right Approach for Each Situation

Here’s the layered rule I use with my team:

  • Repository / Database layer: Wrap the original error with %w, prefix the function name. Example: fmt.Errorf("UserRepo.GetByID: %w", err). Errors from the database driver are preserved but enriched with context.
  • Service / Business logic layer: Define Custom Error Types for specific business errors. Example: ErrUserSuspended, ErrQuotaExceeded. Callers at higher layers use errors.As to handle each type.
  • Handler / API layer: Use errors.As to catch Custom Errors and convert them into appropriate HTTP responses. For unrecognized errors, return 500 and log the full error chain.

For sentinel errors, I only create them when a caller genuinely needs to compare with errors.Is. Avoid creating dozens of Err* variables nobody uses — that’s package-level pollution.

Implementation Guide for Real-World Projects

Step 1: Create a Dedicated apperrors Package

// internal/apperrors/errors.go
package apperrors

import "fmt"

type ErrorCode int

const (
    ErrCodeNotFound     ErrorCode = 404
    ErrCodeUnauthorized ErrorCode = 401
    ErrCodeInternal     ErrorCode = 500
)

type AppError struct {
    Code    ErrorCode
    Message string
    Err     error
}

func (e *AppError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("%s: %v", e.Message, e.Err)
    }
    return e.Message
}

// Unwrap allows errors.Is and errors.As to traverse through
func (e *AppError) Unwrap() error {
    return e.Err
}

func NotFound(msg string) *AppError {
    return &AppError{Code: ErrCodeNotFound, Message: msg}
}

func Unauthorized(msg string) *AppError {
    return &AppError{Code: ErrCodeUnauthorized, Message: msg}
}

func Wrap(err error, msg string) *AppError {
    return &AppError{Code: ErrCodeInternal, Message: msg, Err: err}
}

Step 2: Use It in the Service Layer

// internal/service/user.go
package service

import (
    "errors"
    "fmt"
    "myapp/internal/apperrors"
    "myapp/internal/repository"
)

func (s *UserService) GetUser(id int) (*User, error) {
    user, err := s.repo.FindByID(id)
    if err != nil {
        if errors.Is(err, repository.ErrNotFound) {
            return nil, apperrors.NotFound(fmt.Sprintf("user %d not found", id))
        }
        return nil, apperrors.Wrap(err, "UserService.GetUser")
    }
    return user, nil
}

Step 3: Handle Errors in the HTTP Handler

// internal/handler/user.go
package handler

import (
    "errors"
    "net/http"
    "myapp/internal/apperrors"
)

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    id := parseID(r)
    user, err := h.service.GetUser(id)
    if err != nil {
        var appErr *apperrors.AppError
        if errors.As(err, &appErr) {
            http.Error(w, appErr.Message, int(appErr.Code))
            return
        }
        http.Error(w, "Internal Server Error", 500)
        return
    }
    writeJSON(w, user)
}

Key Best Practices to Remember

  • Log only at the top layer — if every function logs the error, the same error shows up 5 times in the log, making it hard to read. Log exactly once, at the handler or main level.
  • Don’t strip context when wrappingreturn fmt.Errorf("something went wrong: %w", err) is meaningless. Always include the function name or specific information: fmt.Errorf("UserService.Create: %w", err).
  • Implement Unwrap() if you want to support error chains — without Unwrap(), errors.Is and errors.As won’t traverse through your custom struct.
  • Sentinel errors must be exported with the Err prefix — per Go convention: var ErrNotFound = errors.New(...). Don’t name them like notFoundError since callers won’t be able to import them.
  • Don’t silence errors_ = someFunc() or catching an error and returning without logging anything is a dangerous pattern. At minimum, wrap and return it so the layer above can decide what to do.

One small convention my team finds highly effective: every error message starts with PackageName.FunctionName:. Reading production logs immediately tells you which file and function to look at. No special tools or frameworks needed — just team alignment and discipline.

Error handling in Go isn’t syntactically hard. The challenge is staying consistent as the codebase grows. The approach I usually recommend: start with fmt.Errorf + %w for simplicity, add Custom Error Types when you need branching logic, then extract an apperrors package once the team grows beyond 3 people. The final criterion — a new joiner should be able to look at the code for 5 minutes and understand: what to do when an error occurs, where to log it, and how to trace its origin.

Share: