Error handling in Go: sentinels, wrapping, and one middleware
Anyone who has written Go for a while has typed if err != nil more times than they can count. The check is not the hard part. The hard part shows up a few months into a service, when three people have each written their own "user not found" error and a handler has to pick an HTTP status for each. Without a shared shape, every handler grows its own mapping from failure to response. Logs get the same error twice, and clients get a database driver's message in a JSON body.
This post is the setup I use: sentinel errors, one AppError type, wrapping with %w from the repository up, and a middleware at the HTTP edge that logs and responds. The example runs with no dependencies, and the output below comes from running it.
Errors are return values
In Python, JavaScript, or Java, an exception can pass through a function with no sign of it in that function's code. Go makes the error a second return value instead. The whole interface is one method:
type error interface {
Error() string
}
The caller checks it on the next line:
user, err := GetUser(id)
if err != nil {
return err
}
The cost is verbosity. What it buys is that every failure path is visible in the function body. The rest of the post is about making those paths carry useful information.
Sentinels for the code, codes for the clients
The drift problem in its smallest form, three people checking whether a user exists:
return errors.New("user not found")
return errors.New("no such user")
return errors.New("404: user does not exist")
Anyone upstream who needs to know "was this a not-found?" has to parse text. The fix is named error values the codebase agrees on. A sentinel error is a package-level variable, and they live together in one package:
package apperr
var (
ErrNotFound = errors.New("resource not found")
ErrConflict = errors.New("resource already exists")
ErrValidation = errors.New("validation failed")
ErrBadRequest = errors.New("bad request")
ErrInternal = errors.New("internal server error")
)
Code checks for them with errors.Is, which sees through any amount of wrapping (how it does that comes later):
if errors.Is(err, apperr.ErrNotFound) {
// found, no matter how many layers sit on top
}
Sentinels answer one question: what kind of failure was this? They are for Go code. The client does not speak Go, and an HTTP status alone is too coarse: a 400 could mean a malformed email or a missing field. So each sentinel also gets a string code for the JSON body:
const (
CodeNotFound = "RESOURCE_NOT_FOUND"
CodeConflict = "ALREADY_EXISTS"
CodeValidation = "VALIDATION_FAILED"
CodeBadRequest = "BAD_REQUEST"
CodeInternal = "INTERNAL_ERROR"
)
They line up like this:
| Sentinel (Go code) | API code (JSON body) | HTTP status |
|---|---|---|
ErrNotFound |
RESOURCE_NOT_FOUND |
404 |
ErrConflict |
ALREADY_EXISTS |
409 |
ErrValidation |
VALIDATION_FAILED |
422 |
ErrBadRequest |
BAD_REQUEST |
400 |
ErrInternal |
INTERNAL_ERROR |
500 |
The status tells the client the broad category, the code tells it the specific case, and the message is for a human.
The AppError type
One struct carries the status, the code, the safe message, the validation details, and the internal error:
type AppError struct {
Code int // HTTP status
ApiCode string // machine-readable code for clients
Message string // client-safe message
Err error // internal chain, for logs only
Fields map[string]string // field-level validation details
}
func (e *AppError) 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 *AppError) Unwrap() error { return e.Err }
Unwrap is what lets errors.Is and errors.As look inside. Message is the polite version, "user not found". Err is the honest version, with the function name and the driver's text. The client gets the first, the logs get the second.
Nobody builds the struct by hand. There is one constructor per failure kind:
func NewNotFound(resource string, err error) *AppError {
return &AppError{
Code: 404,
ApiCode: CodeNotFound,
Message: fmt.Sprintf("%s not found", resource),
Err: chain(ErrNotFound, err),
}
}
func NewInternal(err error) *AppError {
return &AppError{
Code: 500,
ApiCode: CodeInternal,
Message: "internal server error",
Err: chain(ErrInternal, err),
}
}
// chain puts the sentinel in front of err so errors.Is can find it.
// If err is nil or already carries the sentinel, nothing is added.
func chain(sentinel, err error) error {
if err == nil {
return sentinel
}
if errors.Is(err, sentinel) {
return err
}
return fmt.Errorf("%w: %w", sentinel, err)
}
The chain helper covers two small cases: a nil error would print as %!w(<nil>), and a sentinel the repository already wrapped would print twice. Two %w verbs in one fmt.Errorf need Go 1.20 or later. NewBadRequest, NewConflict, and NewValidation follow the same pattern.
Wrapping from the repository to the service
A database call fails and the log says connection refused. Which call, which function, which id? Each layer needs to add what it knows without throwing away what came before. The %w verb in fmt.Errorf does that: the new error has the new text and keeps the old error inside it. Think of a parcel that gets a new label at every post office and never loses the earlier ones.
The repository adds its function name and the id:
func (r *UserRepo) FindByID(ctx context.Context, id int64) (*User, error) {
var user User
err := r.db.QueryRowContext(ctx,
"SELECT id, name FROM users WHERE id = $1", id,
).Scan(&user.ID, &user.Name)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("UserRepo.FindByID(%d): %w", id, apperr.ErrNotFound)
}
if err != nil {
return nil, fmt.Errorf("UserRepo.FindByID(%d): %w", id, err)
}
return &user, nil
}
The repository turns the driver's "no rows" into the service-wide ErrNotFound, so nothing above it needs to know which database is behind it.
The service is where errors get classified into an AppError:
func (s *UserService) GetUser(ctx context.Context, id int64) (*User, error) {
if id <= 0 {
return nil, apperr.NewBadRequest("invalid user id", nil)
}
user, err := s.repo.FindByID(ctx, id)
if errors.Is(err, apperr.ErrNotFound) {
return nil, apperr.NewNotFound("user", err)
}
if err != nil {
return nil, apperr.NewInternal(fmt.Errorf("UserService.GetUser: %w", err))
}
return user, nil
}
Asking for user 42, which does not exist, gives this error string:
[404] user not found: UserRepo.FindByID(42): resource not found
Left to right: the status, the client's message, the function that failed with its argument, and the sentinel.
How errors.Is and errors.As read the chain
Both functions do the same walk. errors.Is(err, target) compares err with target. On a miss it calls err.Unwrap() and compares again, until it matches or runs out of errors. errors.As(err, &appErr) walks the same way, but at each step checks whether the current error has the type of appErr, then assigns it and stops.
Here is the walk for the user 42 error, one row per link in the chain:
| Step | Error at this link | errors.As(err, &appErr) |
errors.Is(err, ErrNotFound) |
|---|---|---|---|
| 1 |
AppError (404, "user not found") |
match, stop | miss, call Unwrap
|
| 2 | wrapper: UserRepo.FindByID(42): ...
|
miss, call Unwrap
|
|
| 3 |
ErrNotFound (sentinel, no Unwrap) |
match, stop |
errors.As is done at the first link. errors.Is has to reach the last one, and it gets there only because every link in between has an Unwrap method.
Since Go 1.20 an error can wrap several errors through Unwrap() []error, which is what two %w verbs produce. The walk then covers a small tree instead of a line.
The walk stops at the first link with no Unwrap. That is what %v produces: it formats the inner error into text and builds a plain error with no Unwrap, so the chain ends there. The sample program wraps the user 42 error once more with %v and checks again:
after wrapping with v verb, errors.Is: false
Everything still compiles. Only the check stops matching, and the handler falls through to a 500.
The boundary: WriteError, the middleware, and validation
At the HTTP edge the error becomes a response and a log line. The response shape:
type ErrorResponse struct {
Error string `json:"error"` // the API code
Message string `json:"message"`
Code int `json:"code"` // the HTTP status
Fields map[string]string `json:"fields,omitempty"`
}
WriteError pulls the AppError out with errors.As and writes it. Anything else becomes a 500, so an unclassified error never leaks its text to a client:
func WriteError(w http.ResponseWriter, err error) {
var appErr *apperr.AppError
if !errors.As(err, &appErr) {
appErr = apperr.NewInternal(err)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(appErr.Code)
json.NewEncoder(w).Encode(ErrorResponse{
Error: appErr.ApiCode,
Message: appErr.Message,
Code: appErr.Code,
Fields: appErr.Fields,
})
}
Calling WriteError in every handler would still repeat the same lines everywhere. So handlers return an error, and one middleware logs and writes:
type AppHandler func(http.ResponseWriter, *http.Request) error
func ErrorMiddleware(logger *slog.Logger) func(AppHandler) http.HandlerFunc {
return func(h AppHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
err := h(w, r)
if err == nil {
return
}
var appErr *apperr.AppError
if errors.As(err, &appErr) && appErr.Code >= 500 {
// our fault: log at error level
logger.Error("internal error",
"method", r.Method, "path", r.URL.Path, "error", err.Error())
} else {
// the client's mistake: note it and move on
logger.Info("client error",
"method", r.Method, "path", r.URL.Path, "error", err.Error())
}
WriteError(w, err)
}
}
}
The line to look at is the split on Code >= 500. A 404 is not an incident, so it goes out at info level. A 500 is, so it goes out at error level with the full chain.
A handler is now a straight line: parse, call the service, encode. Anything that fails is returned:
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) error {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
return apperr.NewBadRequest("invalid user id", err)
}
user, err := h.service.GetUser(r.Context(), id)
if err != nil {
return err
}
w.Header().Set("Content-Type", "application/json")
return json.NewEncoder(w).Encode(user)
}
Wiring uses the standard library router (path patterns arrived in Go 1.22), and any router that takes an http.HandlerFunc works the same way:
wrap := ErrorMiddleware(logger)
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", wrap(h.GetUser))
Here is one request for user 42 going through all of it. Only the repository knows about the database, and only the middleware logs.
sequenceDiagram
participant C as Client
participant M as ErrorMiddleware
participant H as UserHandler
participant S as UserService
participant R as UserRepo
C->>M: GET /users/42
M->>H: call handler
H->>S: GetUser(42)
S->>R: FindByID(42)
R-->>S: FindByID(42): ErrNotFound
S-->>H: AppError 404 (wraps the above)
H-->>M: return err
Note over M: errors.As -> 404, log at info
M-->>C: 404 {"error":"RESOURCE_NOT_FOUND", ...}
Figure: a missing user, from request to response. Each layer adds one thing and passes the error up.
The sample program prints this for that request and for a bad id:
level=INFO msg="client error" method=GET path=/users/42 error="[404] user not found: UserRepo.FindByID(42): resource not found"
GET /users/42 -> 404 {"error":"RESOURCE_NOT_FOUND","message":"user not found","code":404}
level=INFO msg="client error" method=GET path=/users/abc error="[400] invalid user id: bad request: strconv.ParseInt: parsing \"abc\": invalid syntax"
GET /users/abc -> 400 {"error":"BAD_REQUEST","message":"invalid user id","code":400}
The log line has the repository function and the parse failure. The JSON body has neither.
The per-handler alternative
The middleware is not required. The service can return only sentinels and each handler can map them with a switch on errors.Is. That gives each handler full control and repeats the mapping in every one of them. Fine for a handful of endpoints, costly for dozens.
Validation errors
Validation is the one case that uses Fields:
func ValidateCreateUser(req CreateUserRequest) error {
fields := make(map[string]string)
if strings.TrimSpace(req.Name) == "" {
fields["name"] = "name is required"
}
if !isValidEmail(req.Email) {
fields["email"] = "invalid email format"
}
if len(fields) > 0 {
return apperr.NewValidation(fields)
}
return nil
}
The response carries fields next to the code, and a frontend can highlight each input by name.
Mistakes that break this
Four things undo the design, and all four compile without complaint.
Logging and returning. If the repository logs and the middleware logs again, one failure shows up twice. Log in one place, the middleware. Everywhere else, add context and return.
// wrong: this error will be logged again upstream
if err != nil {
log.Println(err)
return err
}
// right: add context, return, let the middleware log once
if err != nil {
return fmt.Errorf("MyFunc: %w", err)
}
Wrapping with %v. As shown above, the chain ends there, errors.Is and errors.As stop matching, and every error becomes a 500.
Sending err.Error() to the client. The chain has function names, table names, and driver messages in it. Clients get Message, logs get Err.
Comparing error strings. err.Error() == "not found" breaks the first time someone wraps the error or fixes the wording. errors.Is survives both.
Wrapping up
Each layer has one job. The repository says where it failed and turns driver errors into sentinels. The service says what kind of failure it was and builds the AppError. The middleware presents it once, as a log line and a JSON body. The %w verb keeps the three connected, and each mistake above is a way of cutting it.
On an existing codebase, two greps are a good start: %v", err and err.Error() ==. Each hit is a place where an errors.Is check may already be failing quietly. The sample in this post's folder runs with go run .. Change one %w to %v in the service and watch the 404 turn into a 500.
This post does not cover panics, or errors in goroutines that never reach a handler. Both need their own boundary.
Other blog posts
sync.Pool in Go: reusing objects instead of allocating them
HTTP on the wire: framing, keep-alive, and connection pools
Error handling in Go: sentinels, wrapping, and one middleware
Top comments (0)