Error handling in Go is usually the last thing anyone thinks about at the start of a project, and the first thing that causes pain when the codebase grows. An http.Error here, an if err != nil { return } there, a few hardcoded strings scattered across handlers. It works. Until the day you need to add a new error case and realize you have to grep through the entire codebase to make sure nothing slips through.
Go actually gives you everything you need to centralize this cleanly. This article is for developers who know the language basics and want to structure their API’s error handling in a way that scales. We’ll build a three-layer architecture business, API, HTTP where every error is typed, every HTTP decision is made in one place, and nothing internal ever leaks to the client.
The problem
Here’s a handler from a real internal project:
func (c *CapteurController) Delete(w http.ResponseWriter, r *http.Request) {
pathId := r.PathValue("id")
var id pgtype.UUID
err := id.Scan(pathId)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
err = c.DB.Query.DeleteCapteur(r.Context(), id)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
Perfectly readable. But it hides a few problems.
If id isn't a valid UUID, the client gets a 500 when a 400 would be correct, since the client sent a bad request. If the resource doesn't exist, they also get a 500 instead of a 404. And in both cases, the response body is empty: no message, no context, nothing.
This pattern repeats twenty times across the codebase. Each handler makes its own decisions, usually by default. Adding a cross-cutting rule say, always return a 404 for missing resources means touching every handler individually.
The goal is to push all HTTP error decisions into one place, so handlers can simply propagate errors without knowing what happens to them.
How to centralized error handling
Step 1 Sentinel errors
The first step is to define named errors that act as a shared reference across layers. These are called sentinel errors.
// Errors controller/api
var (
ErrInternalServerError = errors.New("internal server error")
ErrInvalidBody = errors.New("invalid request body")
ErrMissingCookie = errors.New("missing session cookie")
ErrMissingParameter = errors.New("missing parameter")
ErrInvalidRouteParam = errors.New("invalid route parameter")
ErrInvalidQueryParam = errors.New("invalid query parameter")
ErrBodyMismatch = errors.New("body mismatch")
ErrInvalidPassword = errors.New("invalid password")
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
)
// Errors DB layer
var (
ErrNotFound = errors.New("not found")
ErrUnique = errors.New("unique constraint violation")
ErrForeignKey = errors.New("foreign key constraint violation")
ErrNotNull = errors.New("not null constraint violation")
ErrInvalidInput = errors.New("invalid input")
ErrTxClosed = errors.New("transaction closed")
ErrUnknown = errors.New("unknown database error")
)
These are compared with errors.Is(err, ErrNotFound). They form a shared vocabulary between layers without exposing any implementation detail.
Why use errors.New instead of a plain string? Because errors.New returns a pointer to an internal struct. Two calls with the same message produce two distinct values:
a := errors.New("not found")
b := errors.New("not found")
fmt.Println(a == b) // false
fmt.Println(errors.Is(a, b)) // false
A sentinel’s identity is its memory address, not its message. That’s what makes them safe: var ErrNotFound = errors.New("not found") declares a unique identity shared across the entire program. Two packages can each have a "not found" error without errors.Is getting confused between them.
Step 2 Adding context without losing identity
Sentinels have one limitation: they carry no context. ErrInvalidBody alone doesn't tell you which field is invalid.
The naive fix would be fmt.Errorf("question title cannot be empty") but then errors.Is(err, ErrInvalidBody) returns false. The error's identity is lost, and the HTTP mapping breaks.
The right approach is to implement Unwrap() on a custom type, wrapping the sentinel inside a richer error:
type BadRequestError struct {
msg string
}
func (e *badRequestError) Error() string { return e.msg }
func (e *badRequestError) Unwrap() error { return ErrInvalidBody }
This type does three things at once: Error() returns the human-readable message, Unwrap() exposes the underlying sentinel, and errors.Is(err, ErrInvalidBody) returns true because Go traverses the chain automatically.
In practice, the caller doesn’t need to know how the error will be consumed:
func (p QuestionProperties) validate() error {
if strings.TrimSpace(p.Title) == "" {
return api.NewBadRequestError("question title cannot be empty")
}
if len(p.AnswerList) < 2 {
return api.NewBadRequestError("question must have at least 2 answers")
}
if validCount != 1 {
return api.NewBadRequestError("question must have exactly one correct answer")
}
return nil
}
The validation layer just returns typed errors. What happens to them is someone else’s problem. That’s the single responsibility principle applied to error handling.
Step 3 Traversing the error chain
In Go, errors form a tree rooted at the error you’re holding. Go traverses that tree through successive calls to Unwrap. Two functions do this traversal in the mapping layer.
errors.Is(err, target) walks the chain recursively until it finds a value equal to target. It answers the question: "does this error contain this sentinel?"
err := NewBadRequestError("titre manquant") // Unwrap() → ErrInvalidBody
errors.Is(err, ErrInvalidBody) // true - find after Unwrap
errors.Is(err, ErrNotFound) // false - not in the list
errors.As(err, &target) does the same traversal but with a type assertion: it finds the first link in the chain that can be assigned to target. Useful when you need to extract data from a concrete error, not just test its identity:
var target *badRequestError
if errors.As(err, &target) {
fmt.Println(target.msg) // Direct access to the field of the concrete type
}
fmt.Errorf("context: %w", err) is a shortcut for building a chain without defining a custom type. It produces an error whose Unwrap() returns err useful for adding log context without needing to inspect the type downstream:
// Propagate with context visible in the logs, not in the HTTP response
return fmt.Errorf("updateQuiz: %w", database.ErrNotFound)
errors.As becomes especially valuable at external layer boundaries databases, third-party APIs where you want to inspect a low-level error and translate it into a business-layer response:
func mapPgErr(err error) error {
if err == nil {
return nil
}
log.Printf("[ERROR] Database : %+v", err.Error())
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
if errors.Is(err, pgx.ErrTxClosed) {
return ErrTxClosed
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "23505": // UNIQUE violation
return ErrUnique
case "23503": // FOREIGN KEY violation
return ErrForeignKey
case "23502": // NOT NULL violation
return ErrNotNull
case "23514", "22P02", "22001": // CHECK, invalid text, truncation
return ErrInvalidInput
}
}
return fmt.Errorf("%w: %v", ErrUnknown, err)
}
errors.As extracts the concrete pgx error to read the PostgreSQL code. Everything above this function has never heard of pgx. It receives ErrNotFound or ErrUnique nothing more.
Step 4 Mapping errors to HTTP responses
All errors converge on a single mapError function:
func mapError(err error) (int, string) {
switch {
case errors.Is(err, database.ErrNotFound):
return http.StatusNotFound, "resource not found"
case errors.Is(err, ErrUnauthorized),
errors.Is(err, ErrMissingCookie):
return http.StatusUnauthorized, ErrUnauthorized.Error()
case errors.Is(err, ErrInvalidPassword):
return http.StatusUnauthorized, "invalid credentials"
case errors.Is(err, ErrForbidden):
return http.StatusForbidden, ErrForbidden.Error()
case errors.Is(err, ErrBodyMismatch):
return http.StatusUnprocessableEntity, ErrBodyMismatch.Error()
case errors.Is(err, database.ErrUnique):
return http.StatusConflict, "resource already exists"
case errors.Is(err, database.ErrInvalidInput),
errors.Is(err, ErrInvalidBody),
errors.Is(err, ErrInvalidQueryParam),
errors.Is(err, ErrInvalidRouteParam),
errors.Is(err, ErrMissingParameter):
return http.StatusBadRequest, err.Error()
default:
return http.StatusInternalServerError, ErrInternalServerError.Error()
}
}
Two things worth noting here.
- For 400 errors, we return err.Error() directly. This is where Unwrap pays off completely: errors.Is recognizes ErrInvalidBody through the chain, and err.Error() returns the message from the concrete type. The client gets "question title cannot be empty", not the generic "invalid request body".
- For ErrInvalidPassword, we deliberately don't return err.Error(). We return "invalid credentials" instead. Returning "invalid password" would implicitly confirm that the account exists but the password is wrong information an attacker can use for account enumeration. The internal message stays in the logs; the client gets something neutral.
Step 5 Wiring it together
type errorResponse struct {
Error string `json:"error"`
}
func HandleError(w http.ResponseWriter, err error) {
if err == nil {
return
}
log.Printf("[ERROR] API: %+v\n", err)
status, message := mapError(err)
WriteJSON(w, status, errorResponse{
Error: message,
})
}
Every handler calls HandleError and returns:
// api/user/controller.go
var CreateUser = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var dto CreateUserDTO
if err := api.ReadBody(r, &dto); err != nil {
api.HandleError(w, err)
return
}
user, err := database.RegisterUser(r.Context(), database.Credentials{
Name: dto.Name,
Password: dto.Password,
})
if err != nil {
api.HandleError(w, err)
return
}
var out UserOutDTO
out.FromDBUser(user)
api.WriteJSON(w, http.StatusCreated, out)
})
The handler has no idea whether the error is a conflict (409), a validation failure (400), or an internal error (500). It delegates that decision entirely to HandleError.
This decoupling has a concrete payoff for maintainability. Need to add an ErrRateLimited that returns a 429? Declare the sentinel, add one case to mapError, and you're done across the entire codebase, zero changes to existing handlers.
Conclusion
The architecture presented here rests on four principles that reinforce each other.
Sentinel errors define a stable error vocabulary, comparable by pointer identity. They are the contract between layers.
Custom types with Unwrap let you enrich that vocabulary with context without breaking comparisons. That's the key to having both useful client messages and reliable HTTP routing.
The centralized mapper isolates HTTP policy from the rest of the code. All that knowledge lives in mapError, making it easy to audit and extend.
Discipline around err.Error() closes the system: you only surface that message for errors whose chain was explicitly written for an end user. Anything coming from an external system gets absorbed by a translation layer before it ever reaches a response.



Top comments (0)