My default approach in modern Go (1.20+) is a fairly conservative one:
- Use sentinel errors for a small number of semantic conditions.
- Wrap errors when adding useful context.
- Rely on
errors.Is and errors.As for inspection.
- Introduce custom error types sparingly, only when they carry meaningful structured information.
I think this scales better than either "everything is a custom type" or "every package invents its own hierarchy."
Here's what that tends to look like.
1. Define semantic sentinels at package boundaries
var ErrNotFound = errors.New("not found")
var ErrConflict = errors.New("conflict")
Then preserve them with wrapping:
if err := repo.Load(ctx, id); err != nil {
return fmt.Errorf("load user %q: %w", id, err)
}
Higher layers don't care whether the failure came from SQL, Redis, or a cache—they care whether it's "not found."
if errors.Is(err, ErrNotFound) {
// 404
}
2. Wrap where you're adding context—not mechanically
I don't subscribe to "wrap every return."
This:
return fmt.Errorf("loading config: %w", err)
is valuable.
This:
func A() error {
if err := B(); err != nil {
return fmt.Errorf("A: %w", err)
}
}
repeated through eight stack frames becomes:
A: B: C: D: E: F: sql: no rows
which often isn't more informative.
My rule is:
- add context when crossing a subsystem boundary
- add context when new information becomes available
- don't restate the obvious
3. Use custom types for structured data
If callers need more than success/failure, a type is appropriate.
type ValidationError struct {
Field string
Err error
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %v", e.Field, e.Err)
}
func (e *ValidationError) Unwrap() error {
return e.Err
}
Consumers use:
var ve *ValidationError
if errors.As(err, &ve) {
log.Println(ve.Field)
}
The key is that the type provides additional information, not just a different name.
4. Hide implementation details
Suppose storage moves from PostgreSQL to DynamoDB.
Avoid exposing:
sql.ErrNoRows
throughout your application.
Instead:
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("%w", ErrNotFound)
}
Your service layer depends on your domain errors, not database errors.
5. Keep errors.Is as the primary branching mechanism
Business logic tends to read cleanly:
switch {
case errors.Is(err, ErrUnauthorized):
case errors.Is(err, ErrConflict):
case errors.Is(err, ErrNotFound):
default:
}
rather than a forest of type assertions.
6. Reserve errors.As for when you need data
Examples include:
*os.PathError
*net.OpError
- validation errors with fields
- API errors carrying status codes or retry hints
If all you need is "is this not found?", errors.Is is usually the simpler choice.
In large codebases
One pattern I've seen age well is a layered approach:
database
sql.ErrNoRows
↓
repository
ErrNotFound
↓
service
wrap with business context
↓
HTTP/gRPC
errors.Is(err, ErrNotFound) → 404
The lower layers translate infrastructure-specific errors into stable, domain-level semantics, while upper layers decide how to present those semantics (HTTP status, gRPC code, CLI exit code, etc.).
What I'd avoid
- Huge inheritance-like error hierarchies.
- Creating a new custom error type for every failure.
- Comparing error strings.
- Wrapping indiscriminately at every function boundary.
- Exposing third-party library errors across package boundaries unless they're intentionally part of your API.
Overall, I think the Go ecosystem has converged on a pragmatic style: use %w for context, errors.Is for semantic checks, errors.As when you need structured information, and custom error types only when they genuinely carry extra data. That keeps APIs stable, call sites readable, and error handling maintainable as a codebase grows.