Custom errors
Build rich error types as structs, wrap errors with %w, and know when to panic instead of returning an error.
- 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 interface-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)) // trueThe %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, nil 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.You have a *QueryError that wraps ErrNotFound via Unwrap(). Which call will successfully detect ErrNotFound?
- 2.It is acceptable to panic in a library function when the caller passes an invalid argument.
- 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.goWhere 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.