Skip to main content

Custom Errors and Error Wrapping in Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 7 min read

The error interface in Go has exactly one method: Error() string. That simplicity is intentional — any type with that method is an error, and callers decide what to do with it. But returning a plain string error from a database call or HTTP request discards information that callers might need: what field failed validation, what HTTP status was returned, what the original underlying error was.

Custom error types and error wrapping solve this by attaching structured data to errors while still satisfying the error interface. This guide covers how to design and use them well. For the broader context of checking and handling errors see Go error handling.

When a Simple Error Isn’t Enough

errors.New and fmt.Errorf create errors that carry a string. That’s fine for simple cases. It’s not enough when:

  • The caller needs to branch based on the error’s type (e.g., show a 400 vs 500 response)
  • The error needs to carry structured fields (field name, record ID, status code)
  • The error wraps an underlying error that should be preserved for errors.Is/errors.As

Custom error types handle all three cases.

Implementing a Custom Error Type

Any struct with an Error() string method is an error. The method should return a readable message; the struct fields carry the machine-readable data:

// ValidationError carries which field failed and why
type ValidationError struct {
    Field   string
    Message string
}

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

Callers that only care about the message treat it like any error:

if err := validateRequest(r); err != nil {
    log.Println(err)  // "validation failed on "email": must contain @"
    return
}

Callers that need the structured data use errors.As to extract the typed value:

var ve *ValidationError
if errors.As(err, &ve) {
    http.Error(w, fmt.Sprintf("bad field: %s", ve.Field), http.StatusBadRequest)
}

Wrapping an Underlying Error

When your function fails because of an inner error, preserve the original by wrapping it. Implement Unwrap() error so that errors.Is and errors.As can traverse the chain:

type DBError struct {
    Op    string  // "INSERT", "SELECT", etc.
    Table string
    Err   error   // underlying driver error
}

func (e *DBError) Error() string {
    return fmt.Sprintf("db %s on %s: %v", e.Op, e.Table, e.Err)
}

// Unwrap lets errors.Is / errors.As find the wrapped error
func (e *DBError) Unwrap() error { return e.Err }

Now errors.Is(err, sql.ErrNoRows) works even when err is a *DBError wrapping a *DBError wrapping sql.ErrNoRows — the chain is traversed automatically:

func getUser(id int) (*User, error) {
    row := db.QueryRow("SELECT * FROM users WHERE id = ?", id)
    if err := row.Scan(&u); err != nil {
        return nil, &DBError{Op: "SELECT", Table: "users", Err: err}
    }
    return &u, nil
}

// Caller can check the specific inner error
user, err := getUser(42)
if errors.Is(err, sql.ErrNoRows) {
    http.Error(w, "not found", http.StatusNotFound)
    return
}

fmt.Errorf with %w: Lightweight Wrapping

For adding context without a custom type, fmt.Errorf("doing X: %w", err) is idiomatic. The %w verb wraps the error, making it accessible via errors.Unwrap:

func loadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("loading config from %s: %w", path, err)
    }
    // ...
}

The resulting error message reads as a chain:

loading config from /etc/app.conf: open /etc/app.conf: permission denied

Each layer adds what it was trying to do without repeating what the inner error already said.

When to use fmt.Errorf vs a custom type: use fmt.Errorf when you only need to add context string. Use a custom type when the caller needs to extract structured data (status codes, field names, retry hints) from the error.

Sentinel Errors vs Custom Types

A sentinel error is a package-level var intended for comparison:

var (
    ErrNotFound   = errors.New("not found")
    ErrPermission = errors.New("permission denied")
)

Callers use errors.Is:

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

A custom type carries more data but requires errors.As to extract it:

var ne *NotFoundError
if errors.As(err, &ne) {
    fmt.Printf("resource %q not found\n", ne.Resource)
}

Use sentinels for simple yes/no conditions. Use custom types when the “what was not found” matters to the caller. You can combine both — a custom type that also wraps a sentinel, so both errors.Is(err, ErrNotFound) and errors.As(err, &ne) work:

type NotFoundError struct {
    Resource string
}

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

func (e *NotFoundError) Is(target error) bool {
    return target == ErrNotFound
}

Implementing Is(error) bool lets your custom type satisfy errors.Is checks against a sentinel, even without wrapping it via %w.

Multi-Error: Collecting Multiple Failures

Validation often produces multiple errors, not just one. Go 1.20’s errors.Join creates an error that wraps multiple errors simultaneously:

func validateUser(u *User) error {
    var errs []error

    if u.Name == "" {
        errs = append(errs, &ValidationError{Field: "name", Message: "required"})
    }
    if !strings.Contains(u.Email, "@") {
        errs = append(errs, &ValidationError{Field: "email", Message: "invalid format"})
    }
    if u.Age < 0 || u.Age > 150 {
        errs = append(errs, &ValidationError{Field: "age", Message: "out of range"})
    }

    return errors.Join(errs...)  // nil if errs is empty
}

errors.Join returns nil if all errors in the slice are nil, making the nil-check pattern work naturally. errors.Is and errors.As check all joined errors:

err := validateUser(u)
var ve *ValidationError
if errors.As(err, &ve) {
    fmt.Println("first validation error:", ve.Field)
}

For APIs that return validation error details to clients, unwrap the joined error manually:

// errors.Unwrap returns nil for joined errors; use errors.Join's own type
type ValidationErrors []error

func (ve ValidationErrors) Error() string {
    msgs := make([]string, len(ve))
    for i, e := range ve {
        msgs[i] = e.Error()
    }
    return strings.Join(msgs, "; ")
}

func (ve ValidationErrors) Unwrap() []error { return []error(ve) }

Domain Error Pattern

For larger applications, define a domain-specific error type that carries everything needed to generate an appropriate response:

type AppError struct {
    Code    int         // HTTP status code
    Message string      // user-facing message
    Detail  string      // internal detail for logging
    Err     error       // wrapped underlying error
}

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

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

// Constructors for common cases
func NotFound(resource string) *AppError {
    return &AppError{Code: 404, Message: resource + " not found"}
}

func BadRequest(detail string) *AppError {
    return &AppError{Code: 400, Message: "bad request", Detail: detail}
}

func Internal(err error) *AppError {
    return &AppError{Code: 500, Message: "internal server error", Err: err}
}

HTTP handlers extract *AppError and write the appropriate response:

func handleGetUser(w http.ResponseWriter, r *http.Request) {
    user, err := svc.GetUser(r.PathValue("id"))
    if err != nil {
        var ae *AppError
        if errors.As(err, &ae) {
            http.Error(w, ae.Message, ae.Code)
        } else {
            http.Error(w, "internal error", 500)
            log.Println("unexpected error:", err)
        }
        return
    }
    json.NewEncoder(w).Encode(user)
}

Avoid These Patterns

Returning error strings that encode structure. Parsing err.Error() with string operations is fragile — use a custom type instead.

Wrapping the same message redundantly. If the inner error says "connection refused", don’t wrap it as "database error: connection refused" — add what your code was doing: "fetching user 42: connection refused".

Using panic for domain errors. Panics bypass the error-return contract and are hard for callers to handle gracefully. Reserve them for programming errors (nil dereference, impossible state).

Ignoring Unwrap on custom types. If your type wraps an inner error but doesn’t implement Unwrap, callers can’t use errors.Is to check the inner error. Always implement Unwrap() error when your type has an Err error field.

Summary

  • Implement Error() string on any struct to make it an error — add fields for structured data callers need
  • Implement Unwrap() error whenever your type wraps another error — enables errors.Is / errors.As chain traversal
  • Use fmt.Errorf("context: %w", err) for lightweight wrapping that adds message context without a new type
  • Combine sentinel errors with custom types by implementing Is(error) bool — lets callers use either errors.Is or errors.As
  • Use errors.Join (Go 1.20+) for validation that produces multiple failures
  • Never parse err.Error() to extract data — use a custom type and errors.As instead

Resources

Comments

👍 Was this article helpful?