Code of the Day
IntermediateInterfaces & errors

Custom errors

Build rich error types as structs, wrap errors with %w, and know when to panic instead of returning an error.

GoIntermediate11 min read
Recommended first
By the end of this lesson you will be able to:
  • Implement the error interface on a struct type
  • Add an Unwrap method to enable errors.Is and errors.As chain walking
  • Use %w in fmt.Errorf to attach context without a custom type
  • Explain the distinction between panic and returning an error
  • Describe common error context patterns used in production Go

The built-in errors.New and fmt.Errorf cover most needs. But when callers need to inspect structured information — not just a message string — you build a custom error type. Go's -based error model makes this straightforward: any struct that implements Error() string is a valid error.

A struct-based error type

type ValidationError struct {
    Field   string
    Message string
}

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

Return it like any other error:

func validateAge(age int) error {
    if age < 0 {
        return &ValidationError{Field: "age", Message: "must be non-negative"}
    }
    return nil
}

Callers who want the details use errors.As:

err := validateAge(-1)
var ve *ValidationError
if errors.As(err, &ve) {
    fmt.Println("invalid field:", ve.Field)
}

Use a pointer receiver (*ValidationError) when implementing Error(). Returning a pointer (&ValidationError{...}) is the norm for custom error types because it avoids copying and works correctly with errors.As.

Adding Unwrap for chain walking

To allow errors.Is and errors.As to look through your custom error at its wrapped cause, add an Unwrap method:

type QueryError struct {
    Query string
    Err   error   // wrapped cause
}

func (e *QueryError) Error() string {
    return fmt.Sprintf("query %q failed: %v", e.Query, e.Err)
}

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

Now wrapping ErrNotFound inside a QueryError still lets errors.Is(err, ErrNotFound) find it:

wrapped := &QueryError{Query: "SELECT *", Err: ErrNotFound}
fmt.Println(errors.Is(wrapped, ErrNotFound))   // true

The %w shortcut

For cases where you only need to add a message — not structured fields — fmt.Errorf with %w is all you need. It creates an internal wrapper type automatically:

func fetchUser(id int) (*User, error) {
    u, err := db.Get(id)
    if err != nil {
        return nil, fmt.Errorf("fetchUser(%d): %w", id, err)
    }
    return u, nil
}

Reserve custom struct types for when callers will use errors.As to access fields. If the only added value is a message prefix, %w is cleaner.

Error context patterns

Two conventions worth adopting immediately:

1. Name the function at each layer:

return fmt.Errorf("loadConfig: %w", err)
return fmt.Errorf("parseJSON: %w", err)

When the chain prints, it reads like a stack trace: "server: loadConfig: parseJSON: unexpected end of input".

2. Keep error messages lowercase and unpunctuated:

Go's error handling style calls for lowercase error strings without trailing periods. They're meant to be embedded in larger messages, not standalone sentences.

// Good
errors.New("connection refused")

// Avoid
errors.New("Connection refused.")

When to panic instead of returning an error

panic exists for genuine programmer errors — situations that should never happen if the code is correct:

func mustPositive(n int) int {
    if n <= 0 {
        panic(fmt.Sprintf("mustPositive: got %d", n))
    }
    return n
}

Guidelines:

  • Return an error for expected failure modes: I/O failures, missing records, invalid user input.
  • Panic for invariant violations: impossible states, pointer dereferences in internal code, programming mistakes.
  • Never panic in library code for normal error conditions — let callers decide what to do.

Functions that panic to encode a "must succeed" contract often have a Must prefix by convention (template.Must, regexp.MustCompile).

A panic unwinds the stack. In a long-running server, an unrecovered panic in a goroutine crashes the process. If you must use panic for recoverable situations, use recover in a deferred function — but reserve that pattern for framework-level code only.

Check your understanding

Knowledge check

  1. 1.
    You have a *QueryError that wraps ErrNotFound via Unwrap(). Which call will successfully detect ErrNotFound?
  2. 2.
    It is acceptable to panic in a library function when the caller passes an invalid argument.
  3. 3.
    Which error message string matches idiomatic Go style?

Do it yourself

Define a NotFoundError struct with an ID int field. Implement Error() string and Unwrap() error (wrapping a sentinel). Write a function that returns it, then use errors.As to extract the ID in main.

go run main.go

Where to go next

You've now built a complete picture of Go's error model. The lab next consolidates interface satisfaction, type assertions, and error wrapping through scenario questions you can explore at your own pace.

Finished reading? Mark it complete to track your progress.

On this page