API error responses in Go: practical tips and trade-offs
Error handling in production APIs feels deceptively simple until a client receives an ambiguous 500 response at 2 AM and your support team has no way to trace it. When you're building production systems with Go, getting API error responses right means distinguishing between what clients need to know and what engineers need to debug, maintaining consistent error formats across microservices, and ensuring that every failure is traceable without leaking internal implementation details. This article walks through the practical patterns we've refined across trading platforms, ride-hailing services, and SaaS products to keep error handling maintainable and debuggable.
One error response struct
The temptation when building APIs is to let errors vary by endpoint. One handler returns {"error": "not found"}, another returns {"message": "User does not exist"}, and a third wraps the Go error directly in JSON. Clients and frontend teams end up writing fragile parsing code that breaks when a new endpoint ships with a different shape.
Define a single error response struct that every handler uses. This becomes your contract with clients.
package api
type ErrorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
Details map[string]interface{} `json:"details,omitempty"`
}
type Error struct {
HTTPStatus int
Code string
Message string
Err error
}
The struct separates what clients see (ErrorResponse) from what your handlers work with (Error). The Code field holds a machine-readable identifier like INSUFFICIENT_BALANCE or RATE_LIMIT_EXCEEDED. Clients use this to decide whether to retry, show a specific UI message, or log an alert. The Message is user-facing; it describes what went wrong in a way that makes sense to an end user or integration engineer reading the docs.
The Details map lets you attach structured metadata without changing the top-level struct. A validation error might include which fields failed and why; a rate limit error might include retry-after seconds.
func writeError(w http.ResponseWriter, err *Error, requestID string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(err.HTTPStatus)
resp := ErrorResponse{
Code: err.Code,
Message: err.Message,
RequestID: requestID,
}
json.NewEncoder(w).Encode(resp)
}
Every response handler calls this function or middleware that injects the request ID. Consistency across endpoints means your monitoring, logging, and client error handling code stays simple.
Map domain errors to status codes in one place
Domain logic often returns rich error types specific to your business. In a payments system, you might have InsufficientFundsError, AccountSuspendedError, or ExchangeRateLimitError. Without a centralized mapping, handlers scatter HTTP status codes throughout the codebase: one handler uses 400, another uses 422 for the same logical error. Clients can't rely on status codes; they have to parse JSON.
Build a mapping function that lives in one place and converts your domain errors to HTTP semantics.
package api
import "errors"
func mapDomainError(err error) *Error {
// Type-check domain errors first
var insufficientFunds *payment.InsufficientFundsError
if errors.As(err, &insufficientFunds) {
return &Error{
HTTPStatus: http.StatusPaymentRequired,
Code: "INSUFFICIENT_FUNDS",
Message: "Your account has insufficient funds for this transaction.",
Err: err,
}
}
var accountSuspended *account.SuspendedError
if errors.As(err, &accountSuspended) {
return &Error{
HTTPStatus: http.StatusForbidden,
Code: "ACCOUNT_SUSPENDED",
Message: "Your account is suspended. Contact support.",
Err: err,
}
}
var validation *ValidationError
if errors.As(err, &validation) {
return &Error{
HTTPStatus: http.StatusUnprocessableEntity,
Code: "VALIDATION_FAILED",
Message: "Request validation failed.",
Details: map[string]interface{}{
"fields": validation.Fields,
},
Err: err,
}
}
// Catch-all for unknown errors
return &Error{
HTTPStatus: http.StatusInternalServerError,
Code: "INTERNAL_ERROR",
Message: "An unexpected error occurred.",
Err: err,
}
}
Handlers now call this mapping function before writing the response:
func (h *Handler) TransferFunds(w http.ResponseWriter, r *http.Request) {
requestID := r.Header.Get("X-Request-ID")
// Parse and validate request
var req TransferRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
apiErr := mapDomainError(err)
writeError(w, apiErr, requestID)
return
}
// Call business logic
result, err := h.svc.TransferFunds(r.Context(), req.From, req.To, req.Amount)
if err != nil {
apiErr := mapDomainError(err)
writeError(w, apiErr, requestID)
h.log.Error("transfer failed", "error", err, "request_id", requestID)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(result)
}
This pattern keeps business logic and HTTP semantics separate. Your domain code doesn't know about status codes. Handlers don't duplicate error logic. Clients see consistent status codes and error codes across your entire API surface.
Include a request ID
Request IDs are non-negotiable in production. When a client reports a problem, they can include the request ID in their support ticket or error report. Your team can grep logs, search your trace database, and reconstruct exactly what happened for that user. Without request IDs, debugging production issues becomes guesswork.
Generate or accept a request ID at the HTTP handler level, inject it into context, and include it in every error response and log line.
package middleware
import (
"context"
"net/http"
"github.com/google/uuid"
)
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID = uuid.New().String()
}
ctx := context.WithValue(r.Context(), "request_id", requestID)
w.Header().Set("X-Request-ID", requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Extracting the request ID from context is straightforward:
func getRequestID(ctx context.Context) string {
id, ok := ctx.Value("request_id").(string)
if !ok {
return "unknown"
}
return id
}
Pass the request ID to your logger at every step. When an error occurs deep in a service call, you can surface it in the error response so the client can provide it when reporting the issue. This turns a vague "something went wrong" into a traceable incident.
Never leak internal error messages
The Go standard library's Error() method on errors can expose internals you don't want clients to see. A database connection error might say "pq: conn refused at 10.0.2.5:5432". A cache miss might say "redis: client disconnected". An API call to a third party might leak authentication headers or stack traces.
Your error response Message must be safe for public consumption. The internal error details stay in server logs.
func mapDomainError(err error) *Error {
// Always log the full error for debugging
// But return a safe message to the client
var dbErr pq.Error
if errors.As(err, &dbErr) {
// Log reveals the real problem
log.Error("database error", "code", dbErr.Code, "detail", dbErr.Detail)
// Client sees a generic message
return &Error{
HTTPStatus: http.StatusInternalServerError,
Code: "DATABASE_ERROR",
Message: "An unexpected error occurred. Please try again.",
Err: err,
}
}
var timeoutErr net.Error
if errors.As(err, &timeoutErr) && timeoutErr.Timeout() {
return &Error{
HTTPStatus: http.StatusGatewayTimeout,
Code: "TIMEOUT",
Message: "Request took too long. Please try again.",
Err: err,
}
}
return &Error{
HTTPStatus: http.StatusInternalServerError,
Code: "INTERNAL_ERROR",
Message: "An unexpected error occurred.",
Err: err,
}
}
When you write the response, only serialize the safe fields:
func writeError(w http.ResponseWriter, err *Error, requestID string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(err.HTTPStatus)
resp := ErrorResponse{
Code: err.Code,
Message: err.Message,
RequestID: requestID,
}
// The raw error (err.Err) never gets serialized to JSON
// It stays in your logs and monitoring systems
json.NewEncoder(w).Encode(resp)
}
In your structured logs, include the full error chain:
h.log.Error("API call failed",
"request_id", requestID,
"code", apiErr.Code,
"error", err,
)
This way, your observability pipeline captures the real problem, but your API client never sees internal details that could be exploited or misunderstood.
Common mistakes
- Returning raw Go errors in JSON: Serializing
err.Error()directly exposes internals and varies wildly. Always map to yourErrorResponsestruct. - Mixing error handling logic across handlers: If each handler implements its own mapping, you'll have inconsistent status codes and codes. Centralize the mapping function.
- Omitting request IDs from logs: When you need to debug, a request ID is your lifeline. Include it everywhere: logs, error responses, spans.
- Using 500 for everything: Clients benefit from knowing whether an error is their fault (4xx) or yours (5xx). Use appropriate status codes and machine-readable error codes so clients can retry intelligently.
- Including details without structure: Shoving unstructured text into the
Messagefield wastes space. Use theDetailsmap or nested structs for structured metadata.
Key takeaways
- Define one
ErrorResponsestruct and use it everywhere. Consistency helps clients and reduces surprises. - Map domain errors to HTTP semantics in a single function. This keeps your handlers clean and your API surface predictable.
- Generate and propagate a request ID through context. Include it in error responses and logs so support and engineering can trace issues.
- Never serialize raw Go errors to JSON. Safe, user-facing messages go to clients; detailed errors go to logs.
- Use machine-readable error codes alongside HTTP status codes. Clients can make smarter decisions about retries and UI messaging.






Comments
Loading comments…