Go Gin Centralized Error Handling Middleware vs Per Handler Responses
Quick answer
Centralized error handling in Gin means handlers record errors with c.Error and a middleware converts them into consistent HTTP responses. Per handler...
Centralized error handling in Gin means handlers record errors with c.Error and a middleware converts them into consistent HTTP responses. Per handler responses are simpler for small APIs, but they tend to drift in format and status codes as the codebase grows. For most production APIs, a centralized approach with typed errors is easier to keep consistent.
What Is the Per Handler Approach and Where Does It Break Down?
In the per handler approach, each handler decides the status code and JSON shape on its own. It is explicit and easy to follow, which is ideal for a handful of endpoints.
user, err := h.svc.Get(ctx, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
Problems appear with scale. Different developers return different error shapes. Internal error messages leak to clients. The same domain error maps to 400 in one handler and 422 in another. Logging becomes inconsistent, so tracing a failure means reading many handlers.
The cost also shows up for API consumers. Client developers have to write special cases for each endpoint, because there is no single contract for what an error looks like. Mobile apps and frontends end up with fragile parsing code that breaks whenever a handler changes its response.
How Does Centralized Error Middleware Work?
Gin collects errors attached with c.Error(err) in c.Errors. A middleware runs the handler chain with c.Next(), then inspects c.Errors and writes one response in a standard format.
type AppError struct {
Status int
Code string
Message string
Err error
}
func (e *AppError) Error() string { return e.Message }
func (e *AppError) Unwrap() error { return e.Err }
func ErrorHandler() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
if len(c.Errors) == 0 || c.Writer.Written() {
return
}
err := c.Errors.Last().Err
var appErr *AppError
if errors.As(err, &appErr) {
c.JSON(appErr.Status, gin.H{"code": appErr.Code, "message": appErr.Message})
return
}
log.Printf("unhandled error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"code": "internal", "message": "internal error"})
}
}
Handlers then call c.Error(err) followed by c.Abort() or return, and the middleware decides what the client sees. Unknown errors become a generic 500 while the real cause is logged, which prevents leaking internals.
How Should You Design Typed Errors?
Define errors in your domain layer, such as ErrNotFound or ErrConflict, and map them to HTTP statuses in one place. This keeps business logic independent of HTTP while still giving clients meaningful responses.
- Wrap errors with fmt.Errorf and %w so errors.Is and errors.As keep working.
- Use stable machine-readable codes in the response, not only text messages.
- Return validation details as a structured list of field errors.
- Log the full error with a request ID, and return only safe messages to clients.
Consider a standard response shape such as RFC 7807 problem details. It is a widely used convention, though you should verify the current specification before adopting its exact fields.
Keep the mapping table small and boring. A short switch that translates a handful of domain errors into statuses is easy to review, test, and extend. A sprawling mapping spread across packages brings back the original inconsistency you were trying to remove.
When Is Each Approach the Right Choice?
Choose per handler responses for prototypes, internal tools, or services with very few endpoints. Choose centralized middleware when multiple developers contribute, when you expose a public API, or when you need consistent logging and observability.
You can mix them. Let handlers write success responses directly and delegate failures to the middleware. Just be careful about ordering: the error middleware must wrap the handlers, and recovery middleware should run so panics become errors rather than crashes. Add a test that sends requests to a few failing endpoints and asserts the response shape, so format drift is caught in CI instead of by your clients.
Migration can be gradual. Introduce the middleware first, convert the noisiest handlers to c.Error, and leave stable ones alone until you touch them. Both styles can coexist during the transition, as long as no handler writes a response and also records an error.
Frequently Asked Questions
Should handlers call c.JSON for errors at all?
With centralized handling, they should not. Calling c.Error and returning keeps the response format in one place and avoids double writes.
How do I avoid writing the response twice?
Check c.Writer.Written() in the middleware, and call c.Abort() in handlers after recording the error so later handlers do not run.
Where does panic recovery fit?
Use gin.Recovery or a custom recovery middleware as the outermost layer, so panics produce a 500 response and a log entry instead of dropping the connection.
Should I expose error messages to clients?
Only safe, intentional messages. Log internal details server-side and return a generic message for unexpected failures.
Summary
Per handler errors are fine for tiny services, but centralized middleware with typed errors scales better. It keeps responses consistent, protects internals, and simplifies logging. Check the Gin documentation for current middleware behavior in your version.