Introduction
Every Go function that can fail returns an error. You've seen it a thousand times: result, err := doSomething(). And then you write if err != nil and move on. It works. But most Go codebases do just enough to not crash, and nowhere near enough to actually help when things go wrong.
Production errors land in your logs stripped of context. You see connection refused and have no idea which service, which host, which part of the request path. You spend twenty minutes tracing through code that a well-wrapped error would have answered in two seconds.
Go's error model is simple by design. That simplicity is a feature, not a limitation. But simple doesn't mean thoughtless. This article is about doing it right.
Errors are values
In Go, error is just an interface with a single method:
type error interface {
Error() string
}That's it. Any type that implements Error() string satisfies the interface. This is what Rob Pike means when he says errors are values: they're not special syntax, not exceptions, not control flow magic. They're ordinary values you can inspect, wrap, compare, log, and return.
The most basic way to create one is errors.New:
import "errors"
func validateToken(token string) error {
if token == "" {
return errors.New("token is required")
}
if len(token) < 32 {
return errors.New("token too short")
}
return nil
}When there's no error, you return nil. The caller checks if err != nil. This pattern repeats everywhere in Go and it is load-bearing. Don't try to shortcut it.
Note
Never ignore errors
Assigning to _ silences the compiler but not production. If a function returns an error, handle it. If you genuinely don't care, that's almost always a bug waiting to happen.
Sentinel errors
Sometimes callers need to know not just that something failed, but what kind of failure it was. The first tool for this is a sentinel error: a package-level variable that represents a specific, named error condition.
package store
import "errors"
var (
ErrNotFound = errors.New("record not found")
ErrAlreadyExists = errors.New("record already exists")
ErrUnauthorized = errors.New("unauthorized")
)func (s *Store) GetUser(id string) (*User, error) {
user, ok := s.cache[id]
if !ok {
return nil, ErrNotFound
}
return user, nil
}The caller can now branch on the specific error type:
user, err := store.GetUser(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
http.Error(w, "user not found", http.StatusNotFound)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}Sentinel errors are part of your package's public API. Name them well. Export them from a single file. Don't scatter them.
Custom error types
Sentinel errors are strings. When you need to carry structured data alongside the failure — an HTTP status code, a field name, a request ID — you need a custom error type.
Any struct that implements Error() string is an error. You can put whatever you need in the struct.
package apierror
import "fmt"
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on field %q: %s", e.Field, e.Message)
}
type APIError struct {
Code int
Message string
Err error
}
func (e *APIError) Error() string {
if e.Err != nil {
return fmt.Sprintf("[%d] %s: %v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}
func (e *APIError) Unwrap() error {
return e.Err
}Notice the Unwrap() error method on APIError. That single method is what makes Go's error chain work. We'll come back to it.
Returning and handling a custom error type looks like this:
func createUser(req CreateUserRequest) (*User, error) {
if req.Email == "" {
return nil, &apierror.ValidationError{
Field: "email",
Message: "is required",
}
}
// ...
}
// In the handler:
user, err := createUser(req)
if err != nil {
var ve *apierror.ValidationError
if errors.As(err, &ve) {
// ve.Field and ve.Message are available here
respondValidationError(w, ve.Field, ve.Message)
return
}
respondInternalError(w, err)
return
}Wrapping with context
The most under-used feature in Go error handling is wrapping. When you call a function and it fails, don't just return the raw error. Add context. Tell the next person reading the log what you were trying to do.
Use fmt.Errorf with the %w verb to wrap an error while preserving the original:
func (s *Service) ProcessOrder(ctx context.Context, orderID string) error {
order, err := s.store.GetOrder(ctx, orderID)
if err != nil {
return fmt.Errorf("fetching order %s: %w", orderID, err)
}
if err := s.inventory.Reserve(ctx, order.Items); err != nil {
return fmt.Errorf("reserving inventory for order %s: %w", orderID, err)
}
if err := s.payments.Charge(ctx, order); err != nil {
return fmt.Errorf("charging payment for order %s: %w", orderID, err)
}
return nil
}Now when this fails in production, your log reads:
charging payment for order ord_8f2k1: [503] upstream timeout: connection refused
Instead of just: connection refused
The wrapping chain tells the whole story. Each layer adds its context without destroying what came before. This is the single highest-leverage change most Go services can make to their error handling.
⚠ Gotcha
Don't wrap at every layer blindly
Wrapping adds value when it adds context. If you're just re-wrapping with the same message the inner function already gives, you're adding noise. Wrap when you can add something the inner error doesn't know: the ID you were looking up, the operation you were trying to perform, the user that triggered it.
errors.Is and errors.As
Once errors are wrapped, equality checks with == break. wrappedErr == store.ErrNotFound will be false because the wrapped error is a different value. That's what errors.Is and errors.As are for.
errors.Is walks the error chain and checks whether any error in the chain matches the target:
err := fmt.Errorf("processing order: %w", store.ErrNotFound)
// This is false — different pointer
err == store.ErrNotFound
// This is true — errors.Is walks the chain
errors.Is(err, store.ErrNotFound)errors.As does the same but for types. It walks the chain and tries to assign to the target pointer. Use it when you need the actual error struct, not just a match:
err := fmt.Errorf("creating user: %w", &apierror.ValidationError{
Field: "email",
Message: "already taken",
})
var ve *apierror.ValidationError
if errors.As(err, &ve) {
// ve is now the unwrapped *ValidationError
fmt.Println(ve.Field) // "email"
fmt.Println(ve.Message) // "already taken"
}✦ Tip
Use errors.Is for sentinel errors, errors.As for structured types
errors.Is is for comparing identity (is this the ErrNotFound I defined?). errors.As is for extracting data (give me the ValidationError so I can read its fields). Know which one you need before you write the check.
Panic and recover
Panic is not error handling. It's for truly unrecoverable states: nil pointer dereferences, out-of-bounds accesses, invariant violations that should never happen if the code is correct. You should almost never call panic explicitly in application code.
That said, HTTP servers need to survive panics in individual handlers without taking down the whole process. That's where recover belongs: at the top of a goroutine, in a deferred function, converting a panic into a 500 response and a logged stack trace.
func RecoverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
buf := make([]byte, 4096)
n := runtime.Stack(buf, false)
log.Printf("panic recovered: %v
%s", rec, buf[:n])
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}Apply this middleware once, at the outermost layer. Everywhere else in your application, return errors. Reserve panic for the category of bugs that mean your program has entered an invalid state and continuing would make things worse.
Patterns worth keeping
Constructor functions for errors
For errors you create frequently, a constructor function is cleaner than inline struct literals and makes refactoring easier:
func NotFound(resource, id string) error {
return &APIError{
Code: 404,
Message: fmt.Sprintf("%s %q not found", resource, id),
}
}
func Unauthorized(reason string) error {
return &APIError{
Code: 401,
Message: reason,
}
}
func Internal(op string, err error) error {
return &APIError{
Code: 500,
Message: op,
Err: err,
}
}Centralised error handling in HTTP
Instead of writing response logic in every handler, have your handlers return errors and let a single function translate them into HTTP responses. This keeps handlers focused on business logic and puts all the HTTP/error mapping in one place.
type HandlerFunc func(w http.ResponseWriter, r *http.Request) error
func Wrap(h HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := h(w, r); err != nil {
handleError(w, err)
}
}
}
func handleError(w http.ResponseWriter, err error) {
var apiErr *apierror.APIError
if errors.As(err, &apiErr) {
respond(w, apiErr.Code, map[string]string{"error": apiErr.Message})
return
}
var ve *apierror.ValidationError
if errors.As(err, &ve) {
respond(w, http.StatusBadRequest, map[string]string{
"error": "validation failed",
"field": ve.Field,
})
return
}
log.Printf("unhandled error: %v", err)
respond(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}// Handlers just return errors — no response logic scattered everywhere
func GetUser(w http.ResponseWriter, r *http.Request) error {
id := chi.URLParam(r, "id")
user, err := userStore.GetUser(r.Context(), id)
if err != nil {
return fmt.Errorf("get user handler: %w", err)
}
return respondJSON(w, http.StatusOK, user)
}
// Wire it up
mux.Get("/users/{id}", handler.Wrap(GetUser))✦ Tip
This pattern scales well
Adding a new error type means updating handleError in one place, not hunting through 40 handlers. Logging, tracing, and metrics all live in one function. The handlers stay clean.
Log once, at the top
A very common mistake is logging the error at every layer that touches it. You end up with four log lines for a single failure, each with different context, and it looks like four separate problems. The rule is simple: log at the boundary where the error is handled and no longer propagated. Everywhere below that point, wrap and return.
// Bad — logs at every level
func (s *Service) GetOrder(ctx context.Context, id string) (*Order, error) {
order, err := s.store.GetOrder(ctx, id)
if err != nil {
log.Printf("store.GetOrder failed: %v", err) // don't log here
return nil, fmt.Errorf("getting order: %w", err)
}
return order, nil
}
// Good — wrap and return, log once at the HTTP handler
func (s *Service) GetOrder(ctx context.Context, id string) (*Order, error) {
order, err := s.store.GetOrder(ctx, id)
if err != nil {
return nil, fmt.Errorf("getting order %s: %w", id, err)
}
return order, nil
}Summary
Go's error handling is verbose by design. That verbosity is a forcing function: it makes you think about failure at every step. The goal isn't to hide that verbosity. The goal is to make every error check count.
- Use
errors.Newfor simple, static error messages. - Use sentinel errors (
var ErrX = errors.New(...)) when callers need to branch on specific failures. - Use custom error types when you need to carry structured data with a failure.
- Always implement
Unwrap() erroron custom types that wrap another error. - Wrap errors with
fmt.Errorf("context: %w", err)at every layer — add what the current layer knows that the inner error doesn't. - Use
errors.Isto match sentinel errors through a chain. - Use
errors.Asto extract a typed error from anywhere in the chain. - Use
recoveronly at goroutine boundaries, never as a substitute for returning errors. - Log errors once, at the top where they're handled. Wrap everywhere else.