Every Go developer has written if err != nil thousands of times. Many coming from languages with exceptions see this as a flaw. I used to agree — until I spent years building production systems in Go and realized that explicit error handling is not a limitation. It is a feature that makes your systems more reliable, more debuggable, and easier to reason about under pressure at 3 AM.
This article walks through the error handling patterns I use in production Go services. No toy examples. Everything here comes from real systems handling real traffic.
Why Go's Error Handling Is Actually Good
Before we get into patterns, let's address the elephant in the room. Go's if err != nil feels verbose because it is verbose. That's the point.
In languages with exceptions, error paths are invisible. A function five levels deep can throw, and unless you've memorized every possible exception in the call chain, you don't know what can fail or where. In Go, every potential failure is right there in your face.
user, err := repo.FindUser(ctx, id)
if err != nil {
return fmt.Errorf("finding user %s: %w", id, err)
}
This line of code tells you three things immediately:
-
FindUsercan fail. - You've decided what to do about that failure.
- The error carries context about what was happening when things went wrong.
That's not boilerplate. That's engineering discipline made explicit. Let's build on it.
Wrapping Errors with Context
The single most impactful error handling improvement you can make is consistent error wrapping. Raw errors without context are nearly useless in production.
Bad:
func GetOrder(ctx context.Context, id string) (*Order, error) {
row := db.QueryRowContext(ctx, "SELECT ... WHERE id = $1", id)
var o Order
if err := row.Scan(&o.ID, &o.Total, &o.Status); err != nil {
return nil, err // "sql: no rows in result set" — useless in a log
}
return &o, nil
}
Good:
func GetOrder(ctx context.Context, id string) (*Order, error) {
row := db.QueryRowContext(ctx, "SELECT ... WHERE id = $1", id)
var o Order
if err := row.Scan(&o.ID, &o.Total, &o.Status); err != nil {
return nil, fmt.Errorf("get order %s: %w", id, err)
}
return &o, nil
}
The %w verb in fmt.Errorf wraps the original error, preserving the full chain. Now when this shows up in your logs, you see get order abc-123: sql: no rows in result set — immediately actionable.
The wrapping rule: each function adds its own context — what it was trying to do and with what inputs — then wraps the underlying error. This builds a natural call-stack-like trace:
processing payment for user u_42: charging card: stripe api: connection refused
You can read this left to right and understand exactly what happened without opening a single source file.
Wrapping Pitfall: Don't Wrap Twice
A common mistake is wrapping in both the callee and the caller with overlapping information:
// Don't do this
func getUser(id string) (*User, error) {
u, err := db.Find(id)
if err != nil {
return nil, fmt.Errorf("failed to get user from database: %w", err)
}
return u, nil
}
func handleRequest(id string) error {
u, err := getUser(id)
if err != nil {
return fmt.Errorf("failed to get user from database: %w", err)
// Result: "failed to get user from database: failed to get user from database: ..."
}
// ...
}
Each layer should add new context. The caller says what it was doing; the callee says what went wrong at its level.
Sentinel Errors
Sentinel errors are package-level variables that represent specific, well-known error conditions. The standard library uses them extensively: io.EOF, sql.ErrNoRows, context.Canceled.
Define sentinel errors when callers need to make decisions based on the kind of error:
package order
import "errors"
var (
ErrNotFound = errors.New("order not found")
ErrAlreadyPaid = errors.New("order already paid")
ErrInvalidStatus = errors.New("invalid order status transition")
)
Use them in your code:
func (r *Repository) GetOrder(ctx context.Context, id string) (*Order, error) {
row := r.db.QueryRowContext(ctx, query, id)
var o Order
if err := row.Scan(&o.ID, &o.Total, &o.Status); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("scanning order %s: %w", id, err)
}
return &o, nil
}
Notice: when we return ErrNotFound, we don't wrap sql.ErrNoRows. We're translating a storage-layer error into a domain-level error. The caller shouldn't know or care that SQL is involved.
Custom Error Types
When you need to carry structured data alongside the error, define a custom type:
type ValidationError struct {
Field string
Message string
Value any
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on field %q: %s (got %v)",
e.Field, e.Message, e.Value)
}
For richer domain errors, you can embed additional context:
type DomainError struct {
Code string // machine-readable: "ORDER_EXPIRED", "INSUFFICIENT_FUNDS"
Message string // human-readable description
Err error // underlying cause (optional)
}
func (e *DomainError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %s: %v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
func (e *DomainError) Unwrap() error {
return e.Err
}
The Unwrap() method is critical — it lets errors.Is and errors.As traverse the error chain through your custom type.
errors.Is and errors.As
These two functions are how you inspect errors in Go 1.13+. They traverse the entire wrapped error chain, which is why Unwrap() matters.
errors.Is: Checking for Specific Errors
order, err := svc.GetOrder(ctx, orderID)
if errors.Is(err, order.ErrNotFound) {
// Return 404 to the client
http.Error(w, "order not found", http.StatusNotFound)
return
}
if err != nil {
// Something unexpected — log and return 500
log.Error("unexpected error", "err", err, "order_id", orderID)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
errors.Is works through wrapping. Even if the error was wrapped three times, errors.Is(err, ErrNotFound) still returns true.
errors.As: Extracting Error Details
var validErr *ValidationError
if errors.As(err, &validErr) {
// We can now access validErr.Field, validErr.Message, validErr.Value
respondJSON(w, http.StatusBadRequest, map[string]any{
"error": "validation_failed",
"field": validErr.Field,
"detail": validErr.Message,
})
return
}
errors.As finds the first error in the chain that matches the target type and sets the pointer. This is Go's type-safe alternative to catch (ValidationException e) in other languages.
Implementing Custom Matching
You can define Is on your custom type for semantic matching:
type NotFoundError struct {
Resource string
ID string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s %s not found", e.Resource, e.ID)
}
// A NotFoundError "is" ErrNotFound regardless of resource/ID
func (e *NotFoundError) Is(target error) bool {
if target == ErrNotFound {
return true
}
_, ok := target.(*NotFoundError)
return ok
}
Now errors.Is(err, ErrNotFound) works even when the actual error is &NotFoundError{Resource: "order", ID: "abc"}.
Error Groups: Handling Concurrent Errors
When you run operations concurrently, you need a strategy for collecting errors. The errgroup package (from golang.org/x/sync) is the standard tool:
import "golang.org/x/sync/errgroup"
func (s *Service) EnrichOrder(ctx context.Context, o *Order) error {
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
user, err := s.users.Get(ctx, o.UserID)
if err != nil {
return fmt.Errorf("fetching user: %w", err)
}
o.User = user
return nil
})
g.Go(func() error {
items, err := s.inventory.GetItems(ctx, o.ItemIDs)
if err != nil {
return fmt.Errorf("fetching inventory: %w", err)
}
o.Items = items
return nil
})
if err := g.Wait(); err != nil {
return fmt.Errorf("enriching order %s: %w", o.ID, err)
}
return nil
}
Key behavior: errgroup.WithContext cancels the derived context when any goroutine returns an error. This means if the user fetch fails, the inventory fetch gets cancelled automatically — no wasted work.
For cases where you need all errors (not just the first), use errors.Join from Go 1.20+:
func ValidateOrder(o *Order) error {
var errs []error
if o.Total <= 0 {
errs = append(errs, &ValidationError{
Field: "total", Message: "must be positive", Value: o.Total,
})
}
if o.UserID == "" {
errs = append(errs, &ValidationError{
Field: "user_id", Message: "required",
})
}
if len(o.Items) == 0 {
errs = append(errs, &ValidationError{
Field: "items", Message: "at least one item required",
})
}
return errors.Join(errs...) // returns nil if errs is empty
}
errors.Join creates a multi-error that errors.Is and errors.As can traverse into each constituent error. This means callers can still use errors.As(err, &validErr) to extract individual validation failures from the joined result.
Structured Error Logging
Logging errors as unstructured strings is a production anti-pattern. Use structured logging so your observability tools can actually work with error data.
// Using log/slog (standard library, Go 1.21+)
func (h *Handler) HandlePayment(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
orderID := chi.URLParam(r, "orderID")
err := h.service.ProcessPayment(ctx, orderID)
if err != nil {
slog.ErrorContext(ctx, "payment processing failed",
"order_id", orderID,
"error", err,
"request_id", middleware.GetReqID(ctx),
"user_id", auth.UserFromContext(ctx),
)
respondError(w, err)
return
}
slog.InfoContext(ctx, "payment processed", "order_id", orderID)
respondJSON(w, http.StatusOK, map[string]string{"status": "paid"})
}
With structured logging, you can query your log aggregator for things like error="*stripe*" AND order_id="ord_*" instead of regex-parsing free text.
Logging Level Strategy
Not all errors deserve the same log level:
func classifyAndLog(ctx context.Context, err error, msg string, attrs ...any) {
switch {
case errors.Is(err, context.Canceled):
// Client disconnected — normal, not worth alerting on
slog.DebugContext(ctx, msg, append(attrs, "error", err)...)
case errors.Is(err, ErrNotFound):
// Expected business case
slog.InfoContext(ctx, msg, append(attrs, "error", err)...)
case isTemporary(err):
// Transient failures — retryable
slog.WarnContext(ctx, msg, append(attrs, "error", err)...)
default:
// Unexpected — wake someone up
slog.ErrorContext(ctx, msg, append(attrs, "error", err)...)
}
}
Error Handling in HTTP Handlers: The Domain Error Pattern
This is the pattern I've used most successfully in production. The idea: your domain/service layer returns domain errors, and a single piece of middleware translates them to HTTP responses.
First, define your domain errors:
package apperr
import "fmt"
type Code string
const (
CodeNotFound Code = "NOT_FOUND"
CodeAlreadyExists Code = "ALREADY_EXISTS"
CodeInvalidInput Code = "INVALID_INPUT"
CodeUnauthorized Code = "UNAUTHORIZED"
CodeForbidden Code = "FORBIDDEN"
CodeConflict Code = "CONFLICT"
CodeInternal Code = "INTERNAL"
CodeUnavailable Code = "UNAVAILABLE"
)
type Error struct {
Code Code `json:"code"`
Message string `json:"message"`
Err error `json:"-"` // never serialize the internal error
}
func (e *Error) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %s: %v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
func (e *Error) Unwrap() error { return e.Err }
// Constructor helpers keep call sites clean
func NotFound(msg string, args ...any) *Error {
return &Error{Code: CodeNotFound, Message: fmt.Sprintf(msg, args...)}
}
func InvalidInput(msg string, args ...any) *Error {
return &Error{Code: CodeInvalidInput, Message: fmt.Sprintf(msg, args...)}
}
func Conflict(msg string, args ...any) *Error {
return &Error{Code: CodeConflict, Message: fmt.Sprintf(msg, args...)}
}
func Forbidden(msg string, args ...any) *Error {
return &Error{Code: CodeForbidden, Message: fmt.Sprintf(msg, args...)}
}
func Internal(err error) *Error {
return &Error{Code: CodeInternal, Message: "internal error", Err: err}
}
Now your service layer speaks in domain terms:
func (s *OrderService) CancelOrder(ctx context.Context, id string) error {
order, err := s.repo.Get(ctx, id)
if err != nil {
return fmt.Errorf("cancel order: %w", err)
}
if order.Status == StatusShipped {
return apperr.Conflict("cannot cancel shipped order %s", id)
}
if order.UserID != auth.UserFromContext(ctx) {
return apperr.Forbidden("not your order")
}
if err := s.repo.UpdateStatus(ctx, id, StatusCancelled); err != nil {
return fmt.Errorf("cancel order %s: %w", id, err)
}
return nil
}
The HTTP translation happens in one place:
func respondError(w http.ResponseWriter, err error) {
var appErr *apperr.Error
if !errors.As(err, &appErr) {
appErr = apperr.Internal(err)
}
status := mapCodeToHTTP(appErr.Code)
respondJSON(w, status, map[string]any{
"error": map[string]any{
"code": appErr.Code,
"message": appErr.Message,
},
})
}
func mapCodeToHTTP(code apperr.Code) int {
switch code {
case apperr.CodeNotFound:
return http.StatusNotFound
case apperr.CodeAlreadyExists:
return http.StatusConflict
case apperr.CodeInvalidInput:
return http.StatusBadRequest
case apperr.CodeUnauthorized:
return http.StatusUnauthorized
case apperr.CodeForbidden:
return http.StatusForbidden
case apperr.CodeConflict:
return http.StatusConflict
case apperr.CodeUnavailable:
return http.StatusServiceUnavailable
default:
return http.StatusInternalServerError
}
}
This pattern gives you:
- Clean service code that doesn't know about HTTP
- Consistent API responses across every endpoint
-
Safe error handling — internal errors never leak to clients (note the
json:"-"tag onErr) - Easy testing — assert on error codes, not HTTP status codes
Common Anti-Patterns to Avoid
1. Logging and Returning
// WRONG: This causes duplicate log entries up the chain
if err != nil {
log.Error("failed to get user", "err", err)
return fmt.Errorf("getting user: %w", err)
}
The rule: either handle the error (log it, return a response, retry) or return it. Never both. If you log and return, every caller that also logs creates exponentially duplicated log lines.
2. Swallowing Errors
// WRONG: Silent failure is the worst kind of failure
result, _ := json.Marshal(data)
The only time you should ignore an error is when you truly don't care about the outcome AND have documented why:
// Best-effort cache update — failure is non-critical
_ = cache.Set(ctx, key, value, ttl)
3. Checking Error Strings
// WRONG: Fragile, breaks if error message changes
if strings.Contains(err.Error(), "not found") {
// ...
}
Use errors.Is or errors.As. Always. String matching is a maintenance nightmare waiting to happen.
4. Wrapping with Non-Descriptive Messages
// WRONG: Adds noise, not signal
return fmt.Errorf("error: %w", err)
return fmt.Errorf("an error occurred: %w", err)
return fmt.Errorf("failed: %w", err)
Each wrapping should add what operation was being attempted and with what parameters:
// RIGHT: Actionable context
return fmt.Errorf("updating user %s email to %q: %w", userID, newEmail, err)
5. Using panic for Error Handling
// WRONG: panic is not control flow
func MustGetConfig(key string) string {
v, err := getConfig(key)
if err != nil {
panic(err)
}
return v
}
Reserve panic for truly unrecoverable situations during initialization (e.g., a required config file is missing at startup). Never panic in code that handles requests.
6. Over-Wrapping in Utility Functions
// WRONG: os.ReadFile already includes the path in its error
func ReadFile(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("ReadFile failed for path %s: %w", path, err)
}
return data, nil
}
os.ReadFile already includes the path in its error message. Wrapping at this level adds noise. Wrap at the level where you have business context:
cfg, err := ReadFile(configPath)
if err != nil {
return fmt.Errorf("loading app config: %w", err)
}
Putting It All Together
Here is a complete handler showing these patterns working in concert:
func (h *PaymentHandler) CreatePayment(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req CreatePaymentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, apperr.InvalidInput("malformed request body"))
return
}
if err := req.Validate(); err != nil {
respondError(w, err) // Validate returns *apperr.Error
return
}
payment, err := h.service.CreatePayment(ctx, req.OrderID, req.Method)
if err != nil {
slog.ErrorContext(ctx, "payment creation failed",
"order_id", req.OrderID,
"method", req.Method,
"error", err,
)
respondError(w, err)
return
}
slog.InfoContext(ctx, "payment created",
"payment_id", payment.ID,
"order_id", req.OrderID,
)
respondJSON(w, http.StatusCreated, payment)
}
And the service layer that handles business logic and returns domain errors:
func (s *PaymentService) CreatePayment(
ctx context.Context, orderID, method string,
) (*Payment, error) {
order, err := s.orders.Get(ctx, orderID)
if err != nil {
return nil, fmt.Errorf("create payment: %w", err)
}
if order.Status != StatusPending {
return nil, apperr.Conflict(
"order %s is %s, expected pending", orderID, order.Status,
)
}
charge, err := s.stripe.Charge(ctx, order.Total, method)
if err != nil {
var stripeErr *stripe.Error
if errors.As(err, &stripeErr) && stripeErr.Code == stripe.CardDeclined {
return nil, apperr.InvalidInput("card was declined")
}
return nil, apperr.Internal(
fmt.Errorf("charging order %s: %w", orderID, err),
)
}
payment := &Payment{
ID: uuid.New().String(),
OrderID: orderID,
ChargeID: charge.ID,
Amount: order.Total,
Status: "completed",
}
if err := s.repo.Save(ctx, payment); err != nil {
// Payment was charged but save failed — critical inconsistency
slog.ErrorContext(ctx, "CRITICAL: payment charged but save failed",
"charge_id", charge.ID,
"order_id", orderID,
"error", err,
)
return nil, apperr.Internal(
fmt.Errorf("saving payment for order %s: %w", orderID, err),
)
}
return payment, nil
}
Notice how the service:
- Translates external errors (Stripe) into domain errors
- Adds context at each wrapping level
- Uses appropriate error codes (
Conflict,InvalidInput,Internal) - Logs at the right level — only the critical case (payment charged but save failed) logs in the service; normal errors propagate up to the handler
Final Thoughts
Go's error handling is verbose by design. That verbosity buys you explicitness, and explicitness buys you reliability. The patterns in this article — consistent wrapping, domain errors, structured logging, centralized HTTP translation — turn that verbosity into a system that's genuinely pleasant to debug at 3 AM.
The key takeaways:
-
Wrap with context at every level — use
%wand describe what you were doing. - Use sentinel errors and custom types for errors callers need to act on.
- Translate errors at boundaries — storage errors become domain errors, domain errors become HTTP responses.
- Log or return, never both — pick one responsibility per function.
-
Use
errors.Isanderrors.As— never string-match errors. - Structure your logs — your future self will thank you.
Error handling isn't the glamorous part of Go. But it's the part that determines whether your 2 AM pages are "look at this clear error chain" or "something went wrong somewhere, good luck."
Choose the first one.
If this article helped you, consider buying me a coffee on Ko-fi! Follow me for more production backend patterns.
You Might Also Like
- BullMQ Job Queues in Node.js: Background Processing Done Right (2026 Guide)
- Building Your First MCP Server in TypeScript: Connect AI Agents to Anything
- Distributed Locking: Preventing Race Conditions Across Microservices (2026 Guide)
Follow me for more production-ready backend content!
If this helped you, buy me a coffee on Ko-fi!
Top comments (0)