Every Go codebase I have joined had the same weak spot: errors. Not the if err != nil part — everybody writes that — but everything after it. Errors get logged three times on the way up, lose their context somewhere in the middle, or get compared with err.Error() == "not found". In this post I want to share the small set of rules I now apply everywhere, and the standard library features that make them work.
The One Rule That Fixes Most of It
Handle an error once. Everywhere else, add context and pass it up.
“Handling” means doing something a caller cannot: returning a 404, retrying, falling back to a default, logging and moving on. If you are not doing one of those, you are just a link in the chain — and a link’s only job is to explain where it sits.
// Bad: the error is logged here and returned, so it will be logged again
// by every caller on the way up.
func (s *Store) User(ctx context.Context, id int64) (*User, error) {
u, err := s.query(ctx, id)
if err != nil {
log.Printf("failed to load user: %v", err)
return nil, err
}
return u, nil
}
// Good: add what this layer knows, and let the caller decide.
func (s *Store) User(ctx context.Context, id int64) (*User, error) {
u, err := s.query(ctx, id)
if err != nil {
return nil, fmt.Errorf("load user %d: %w", id, err)
}
return u, nil
}
The second version produces messages that read like a stack trace written in English:
handle GET /users/42: load user 42: query users: sql: no rows in result set
Wrapping With %w
fmt.Errorf has a special verb, %w, that wraps the original error instead of flattening it to a string. The result behaves like a normal error, but the original stays reachable underneath.
_, err := os.Open("/etc/app/config.yaml")
wrapped := fmt.Errorf("read config: %w", err)
fmt.Println(wrapped)
// read config: open /etc/app/config.yaml: no such file or directory
fmt.Println(errors.Is(wrapped, os.ErrNotExist)) // true
That last line is the whole point. %v would have given you the same message, but errors.Is would have returned false because the chain was broken.
A few conventions that keep the output readable:
- Start the message with a lowercase verb phrase describing what this function was doing:
"load user","encode response","dial redis". - Do not end with punctuation, and do not include the word “error” or “failed” — the chain already reads as a failure.
- Put the wrapped error last, after a colon and a space.
Since Go 1.20 you can wrap more than one error in a single call, which is handy when you are cleaning up:
err := doWork()
if cerr := f.Close(); cerr != nil {
err = fmt.Errorf("work failed: %w; close failed: %w", err, cerr)
}
Sentinel Errors: For Conditions Callers Branch On
A sentinel is a package-level error value that callers are meant to recognise.
package store
import "errors"
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
ErrForbidden = errors.New("forbidden")
)
Return them wrapped, so the caller gets both the identity and the context:
func (s *Store) User(ctx context.Context, id int64) (*User, error) {
u, err := s.query(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("load user %d: %w", id, ErrNotFound)
}
if err != nil {
return nil, fmt.Errorf("load user %d: %w", id, err)
}
return u, nil
}
And check them with errors.Is, which walks the whole chain:
u, err := store.User(ctx, id)
switch {
case errors.Is(err, store.ErrNotFound):
http.Error(w, "user not found", http.StatusNotFound)
return
case err != nil:
log.Error("load user", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
Never compare with == when the error might be wrapped, and never compare error strings. errors.Is(err, ErrNotFound) keeps working when someone adds another wrapping layer three months from now; err == ErrNotFound silently stops working.
Keep the list short. Sentinels are part of your package’s public API — every one you export is a promise. If callers cannot meaningfully branch on it, it should not be a sentinel.
Custom Error Types: When You Need to Carry Data
A sentinel says what went wrong. A custom type also says with what. Reach for one when the caller needs a field, not just an identity.
// ValidationError reports a single field that failed validation.
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("field %q is invalid: %s", e.Field, e.Reason)
}
errors.As finds it anywhere in the chain and assigns it to your variable:
var vErr *ValidationError
if errors.As(err, &vErr) {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"field": vErr.Field,
"reason": vErr.Reason,
})
return
}
Two details that trip people up:
- Pass a pointer to the target.
errors.As(err, &vErr)wherevErris already a*ValidationError. PassingvErrdirectly panics. - Be consistent about pointer vs. value receivers. If
Error()is defined on*ValidationError, then only*ValidationErrorimplementserror. Return&ValidationError{...}, and match against*ValidationError.
If your type wraps another error, give it an Unwrap method so errors.Is can keep walking:
type QueryError struct {
Query string
Err error
}
func (e *QueryError) Error() string { return e.Query + ": " + e.Err.Error() }
func (e *QueryError) Unwrap() error { return e.Err }
Now errors.Is(err, sql.ErrNoRows) still works even with a QueryError in the middle.
Collecting Several Errors With errors.Join
When you validate a whole struct, failing on the first problem makes for a frustrating API. errors.Join (Go 1.20) combines errors into one value that errors.Is and errors.As can still search.
func (r CreateUserRequest) Validate() error {
var errs []error
if r.Email == "" {
errs = append(errs, &ValidationError{Field: "email", Reason: "required"})
}
if len(r.Password) < 12 {
errs = append(errs, &ValidationError{Field: "password", Reason: "too short"})
}
// Join returns nil when every element is nil, so this is safe as-is.
return errors.Join(errs...)
}
The joined error prints one message per line, and errors.As will find the first *ValidationError inside it.
Which Tool for Which Job
| Situation | Reach for |
|---|---|
| Adding context on the way up | fmt.Errorf("...: %w", err) |
| Caller branches on a known condition | Sentinel + errors.Is |
| Caller needs data about the failure | Custom type + errors.As |
| Several independent failures at once | errors.Join |
| Failure is expected and unremarkable | Return a plain value, not an error |
That last row matters more than it looks. A cache miss is not an error. An empty search result is not an error. Reserve errors for situations where the caller genuinely cannot continue as planned.
Errors at the HTTP Boundary
The boundary is where errors finally get handled, and it is worth doing that in exactly one place. Middleware is a natural home for it — the same pattern as the Go middleware example, just applied to errors instead of responses.
// handlerFunc is like http.HandlerFunc but may return an error.
type handlerFunc func(http.ResponseWriter, *http.Request) error
// wrap turns a handlerFunc into a plain http.Handler, translating
// errors into status codes in one place.
func wrap(h handlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := h(w, r)
if err == nil {
return
}
var vErr *ValidationError
switch {
case errors.Is(err, store.ErrNotFound):
http.Error(w, "not found", http.StatusNotFound)
case errors.Is(err, store.ErrForbidden):
http.Error(w, "forbidden", http.StatusForbidden)
case errors.As(err, &vErr):
http.Error(w, vErr.Error(), http.StatusBadRequest)
case errors.Is(err, context.Canceled):
// The client hung up. Nothing to report.
return
default:
// Log the full chain, tell the client nothing.
slog.Error("request failed",
"method", r.Method, "path", r.URL.Path, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
})
}
Handlers become quiet and linear:
func getUser(w http.ResponseWriter, r *http.Request) error {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
return &ValidationError{Field: "id", Reason: "must be an integer"}
}
u, err := store.User(r.Context(), id)
if err != nil {
return fmt.Errorf("get user: %w", err)
}
return json.NewEncoder(w).Encode(u)
}
The context.Canceled case is easy to forget and shows up constantly in production: a user closes the tab, the request context is cancelled, and your dashboard fills with 500s that nothing went wrong for. If you have not met that machinery yet, understanding Golang context covers where those cancellations come from.
Common Pitfalls
| Pitfall | What to do instead |
|---|---|
if err.Error() == "not found" |
errors.Is(err, ErrNotFound) |
%v when the caller needs the chain |
%w |
| Logging and returning the same error | Return it; log once at the boundary |
errors.As(err, vErr) |
errors.As(err, &vErr) — pass a pointer |
| Wrapping with the caller’s own function name | Describe the operation, not the function |
| Exporting a sentinel nobody branches on | Keep it unexported, or drop it |
One more, subtle enough to deserve its own note: a non-nil interface holding a nil pointer is not nil.
func find() *ValidationError { return nil }
func check() error {
return find() // returns a non-nil error holding a nil *ValidationError
}
fmt.Println(check() == nil) // false — almost certainly not what you wanted
Declare the return type as error and return a literal nil, or check the concrete value before returning it.
Testing Error Paths
Assert on identity and type, never on the message text. Messages are for humans and will change.
func TestUserNotFound(t *testing.T) {
_, err := store.User(context.Background(), 999)
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("got %v, want ErrNotFound", err)
}
}
func TestValidation(t *testing.T) {
err := CreateUserRequest{}.Validate()
var vErr *ValidationError
if !errors.As(err, &vErr) {
t.Fatalf("got %v, want *ValidationError", err)
}
if vErr.Field != "email" {
t.Errorf("got field %q, want email", vErr.Field)
}
}
This pairs nicely with mocking the database layer, which I covered in how to test database interactions in Golang applications — you can force sql.ErrNoRows and check that your store translates it into ErrNotFound.
Conclusion
Go’s error handling is verbose, but it is also unusually honest: every failure is a value you can inspect, wrap and route. Wrap with %w on the way up, export a small set of sentinels for the conditions callers care about, use custom types when they need the details, and handle everything exactly once at the boundary. Do that and the if err != nil blocks stop feeling like noise and start reading like documentation.
One case that breaks the usual rules and deserves a look: an LLM call can return HTTP 200 while declining to answer, so err is nil and there is nothing in the content. Calling an LLM from Go covers that branch.