Error translation in Go services
Table of contents
In a layered Go service, it’s easy to accidentally leak storage errors like
sql.ErrNoRows all the way up to the handler, or worse, to the client. This
post shows how to catch those at the service boundary, translate them into
domain errors, and keep internal details from reaching places they shouldn’t.
When the handler knows your database #
Say you have a user service backed by Postgres. The handler fetches a user by ID and needs to distinguish “not found” from an actual failure:
// handler.go
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
u, err := h.svc.GetUser(r.Context(), id)
if errors.Is(err, sql.ErrNoRows) { // (1)
http.Error(w, "not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(u)
}
- (1) This is the coupling. The handler imports
database/sqland checks forsql.ErrNoRows, a storage-specific error. The handler now knows the service uses SQL.
For a small service with one database and one transport, that’s a reasonable tradeoff. You know it’s SQL, and nothing else is going to change anytime soon.
Then the service grows. Someone puts Redis in front of Postgres as a read-through cache, and now there are two different “not found” errors:
// handler.go
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, redis.Nil) {
http.Error(w, "not found", http.StatusNotFound)
return
}
The handler now imports two storage packages. It knows the service uses both
Postgres and Redis. Then you add soft deletes. A soft-deleted user exists in
both Postgres and Redis, so neither sql.ErrNoRows nor redis.Nil fires for
it. But the service considers the user gone. The handler has no way to return
404 for this case because neither storage error applies.
Then someone adds a gRPC handler for the same service:
// handler.go
func (h *Handler) GetUser(
ctx context.Context, req *pb.GetUserRequest,
) (*pb.GetUserResponse, error) {
u, err := h.svc.GetUser(ctx, req.GetId())
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, redis.Nil) { // (1)
return nil, status.Error(codes.NotFound, "not found")
}
if err != nil {
return nil, status.Error(codes.Internal, "internal error")
}
return &pb.GetUserResponse{
Id: u.ID, Name: u.Name, Email: u.Email,
}, nil
}
- (1) The same storage error checks from the HTTP handler, duplicated here.
The gRPC handler also imports
database/sqlandredisand maps the same storage errors to a different output format (codes.NotFoundinstead ofhttp.StatusNotFound).
Now two handlers know about sql.ErrNoRows and redis.Nil. Adding a third
storage backend or removing Redis means updating both. Every change to storage
ripples into transport code that shouldn’t care how data is stored.
The handler shouldn’t need to know any of this. It should check for a single “not found” error and return 404 regardless of whether the cause was a missing SQL row, a Redis miss, or a soft delete. That means the service needs its own error types.
Defining domain errors #
When sql.ErrNoRows passes through the service and reaches the handler, it
becomes part of the interface between those layers. Swap Postgres for DynamoDB
and the handler breaks, defeating the whole purpose of having a repository
layer in between. The service package can prevent this by defining errors that
describe what went wrong in business terms:
// user/user.go
package user
import (
"context"
"errors"
"time"
)
type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
)
type Store interface {
Get(ctx context.Context, id int64) (User, error)
Create(ctx context.Context, u User) (int64, error)
}
ErrNotFound means the user doesn’t exist. It doesn’t say why. A missing SQL
row, an expired Redis key, and a soft-deleted record all produce the same
error. The handler doesn’t need to distinguish between these cases because in
all three, the response is a 404.
ErrConflict means a uniqueness constraint would be violated. Whether that’s
a SQL UNIQUE index or a DynamoDB conditional check is for the storage package
to worry about.
With these defined, the repository is where the mapping happens: catch storage-specific errors and return domain errors instead.
Catching storage errors in the repository #
Here’s the SQLite implementation of the repository interface. The two error paths handle things differently on purpose:
// sqlite/store.go
func (s *UserStore) Get(
ctx context.Context, id int64,
) (user.User, error) {
row := s.db.QueryRowContext(ctx,
"SELECT id, name, email FROM users WHERE id = ?", id)
var u user.User
if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return user.User{}, fmt.Errorf(
"user %d not in db: %w", id, user.ErrNotFound, // (1)
)
}
return user.User{}, fmt.Errorf(
"querying user %d: %v", id, err, // (2)
)
}
return u, nil
}
The two paths use different format verbs and wrap different things:
- (1)
%wwrapsuser.ErrNotFound, which is a domain error that this package owns. Callers canerrors.Is(err, user.ErrNotFound)to check for it, and the message"user 42 not in db: not found"tells you where the error came from during debugging. The originalsql.ErrNoRowsis gone from the chain entirely. Nothing above the repository can reach it. - (2)
%vwraps the rawerrfromdatabase/sql. This is a storage error that callers shouldn’t be able to inspect programmatically.%vpreserves the error message for logging but severs the chain, soerrors.Is(err, sql.ErrWhatever)won’t match. If I used%where, callers coulderrors.Isthrough todatabase/sqltypes and the coupling would come back. I wrote more about this choice in Go errors: to wrap or not to wrap? .
The rule is: use %w for your own domain errors (callers should inspect
them), %v for storage errors (callers shouldn’t).
For creates, constraint violations get the same treatment:
// sqlite/store.go
func (s *UserStore) Create(
ctx context.Context, u user.User,
) (int64, error) {
res, err := s.db.ExecContext(ctx,
"INSERT INTO users (name, email) VALUES (?, ?)",
u.Name, u.Email,
)
if err != nil {
if sqliteErr, ok := errors.AsType[sqlite3.Error](err); ok &&
sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
return 0, fmt.Errorf(
"user %s already exists: %w", // (1)
u.Email, user.ErrConflict,
)
}
return 0, fmt.Errorf("inserting user: %v", err) // (2)
}
return res.LastInsertId()
}
- (1) Same pattern as
Get. A database-specific constraint error becomesuser.ErrConflictwrapped with%wand the conflicting email for debugging context. The handler sees “conflict” and returns 409. It doesn’t know which database or which constraint was violated. - (2) Unknown errors get
%vwrapping, same as before. The message is preserved for logging but the chain is severed.
The service layer doesn’t need to do any mapping of its own. It passes domain errors from the store straight through. When it has business reasons to produce the same error independently, it uses the same sentinel:
// user/service.go
func (s *Service) GetUser(
ctx context.Context, id int64,
) (User, error) {
u, err := s.store.Get(ctx, id)
if err != nil {
return User{}, err // (1)
}
if u.DeletedAt != nil {
return User{}, fmt.Errorf(
"user %d soft-deleted: %w", id, ErrNotFound, // (2)
)
}
return u, nil
}
- (1) If the store returned
ErrNotFound(missing row), it passes through unchanged. The service doesn’t translate anything here because the error is already in domain terms. - (2) A soft-deleted user exists in the database but is logically gone. The
service wraps
ErrNotFoundwith%wand the user ID.%wis appropriate here becauseErrNotFoundis the service’s own error, not a leaked storage detail. The handler can still match it witherrors.Is(err, ErrNotFound).
Important
You don’t need to translate at every layer. The repository maps storage errors to domain errors. The handler maps domain errors to wire format. The service layer in between just passes domain errors through unchanged. Two translation points, not one per layer.
Once the repository handles the storage-to-domain mapping, the handler gets much simpler.
Mapping domain errors to status codes #
Compare this to the handler from the beginning of the post. No database/sql
import, no redis import, no knowledge of which storage backends exist:
// main.go
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
u, err := h.svc.GetUser(r.Context(), id)
if err != nil {
writeError(w, err)
return
}
json.NewEncoder(w).Encode(u)
}
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Email string `json:"email"`
}
json.NewDecoder(r.Body).Decode(&req)
u, err := h.svc.CreateUser(r.Context(), req.Name, req.Email)
if err != nil {
writeError(w, err)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(u)
}
All error-to-status mapping lives in one function. Domain errors go in, HTTP status codes come out:
// main.go
func writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, user.ErrNotFound):
http.Error(w, "not found", http.StatusNotFound) // (1)
case errors.Is(err, user.ErrConflict):
http.Error(w, "conflict", http.StatusConflict) // (2)
default:
http.Error(w, "internal error", http.StatusInternalServerError) // (3)
}
}
- (1)
ErrNotFoundbecomes 404. The handler doesn’t know if it was a SQL miss, a Redis miss, or a soft delete. It doesn’t need to. - (2)
ErrConflictbecomes 409. The handler doesn’t know which constraint was violated. - (3) Anything else becomes 500 with a generic message. No internal details leak to the client.
The gRPC handler uses the same service with a different mapping function:
// main.go
func toStatus(err error) error {
switch {
case errors.Is(err, user.ErrNotFound):
return status.Error(codes.NotFound, "not found") // 404 equivalent
case errors.Is(err, user.ErrConflict):
return status.Error(codes.AlreadyExists, "conflict") // 409 equivalent
default:
return status.Error(codes.Internal, "internal error")
}
}
// main.go
func (h *handler) GetUser(
ctx context.Context, req *api.GetUserRequest,
) (*api.GetUserResponse, error) {
u, err := h.svc.GetUser(ctx, req.GetId())
if err != nil {
return nil, toStatus(err)
}
return &api.GetUserResponse{
Id: u.ID, Name: u.Name, Email: u.Email,
}, nil
}
func (h *handler) CreateUser(
ctx context.Context, req *api.CreateUserRequest,
) (*api.CreateUserResponse, error) {
u, err := h.svc.CreateUser(
ctx, req.GetName(), req.GetEmail(),
)
if err != nil {
return nil, toStatus(err)
}
return &api.CreateUserResponse{Id: u.ID}, nil
}
writeError and toStatus have the same shape. One outputs HTTP status
codes, the other outputs gRPC status codes. The service behind both is
identical. If you add a new error like ErrForbidden, you define one sentinel
in the user package and add one case to each mapping function.
What you lose and how to get it back #
When the handler sees ErrNotFound, it doesn’t know whether that was a SQL
miss, a Redis miss, or a soft delete. That’s the whole point of the
translation, but during an incident you need that information.
This is why the repository and service wrap ErrNotFound with descriptive
context using %w, as shown above. The repository produces
"user 42 not in db: not found" and the service produces
"user 42 soft-deleted: not found". Same domain error, different origin. The
handler treats both as 404, but the error strings are distinct.
To make this useful, the handler logs the full error before returning the response:
// main.go
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
u, err := h.svc.GetUser(r.Context(), id)
if err != nil {
slog.ErrorContext(r.Context(), "get user failed",
"user_id", id,
"err", err,
)
writeError(w, err) // responds with "not found", not err.Error()
return
}
json.NewEncoder(w).Encode(u)
}
The client sees a 404 with the body not found. The on-call engineer sees
this:
level=ERROR msg="get user failed" user_id=42
err="user 42 not in db: not found"
The error string tells you which code path produced the error. If you have tracing set up, the request-scoped context carries the trace ID too, so you can follow the 404 all the way back to the storage call that failed.
Precedent in the standard library #
This pattern shows up in several places in the standard library. The os
package is the clearest example. On Linux, opening a file that doesn’t exist
returns syscall.ENOENT. On Windows, the same operation returns
syscall.ERROR_FILE_NOT_FOUND. If callers had to check for every
platform-specific syscall error, any code that opens a file would be full of
OS-specific branches.
The os package translates at the boundary. The Is method on
syscall.Errno maps each platform error number to a portable fs.Err*
sentinel:
// From syscall/syscall_unix.go (simplified)
func (e Errno) Is(target error) bool {
switch target {
case oserror.ErrPermission:
return e == EACCES || e == EPERM // (1)
case oserror.ErrExist:
return e == EEXIST || e == ENOTEMPTY
case oserror.ErrNotExist:
return e == ENOENT // (2)
}
return false
}
- (1) Multiple platform-specific errors (
EACCES,EPERM) map to one portable sentinel (oserror.ErrPermission). Same idea as mapping bothsql.ErrNoRowsandredis.Nilto a singleErrNotFound. - (2)
ENOENT(Linux “no such file”) maps tooserror.ErrNotExist, which is the same value asfs.ErrNotExist. On Windows, a differentErrno.IsmapsERROR_FILE_NOT_FOUNDto the same sentinel. Callers check one error, works everywhere.
When you call os.Open on a missing file, the os package wraps the syscall
error in a *fs.PathError:
// Example usage
f, err := os.Open("/etc/missing.yaml")
// err is &fs.PathError{
// Op: "open",
// Path: "/etc/missing.yaml",
// Err: syscall.ENOENT,
// }
errors.Is(err, fs.ErrNotExist) traverses the chain. *fs.PathError
implements Unwrap(), which returns syscall.ENOENT. Then errors.Is calls
ENOENT.Is(fs.ErrNotExist), which hits the switch above and returns true:
// Example usage
if errors.Is(err, fs.ErrNotExist) {
// works on Linux, macOS, and Windows
}
database/sql does a similar thing. When QueryRow finds no matching rows,
the underlying driver signals end of results through its own internal
protocol. Row.Scan catches that condition and returns sql.ErrNoRows:
// From database/sql/sql.go (simplified)
func (r *Row) Scan(dest ...any) error {
if r.err != nil {
return r.err
}
defer r.rows.Close()
if !r.rows.Next() { // (1)
if err := r.rows.Err(); err != nil {
return err
}
return ErrNoRows // (2)
}
// ...
}
- (1)
r.rows.Next()calls into the database driver. When there are no rows, the driver signals end-of-results through its own protocol. That signal never reaches the caller. - (2)
Scanreturnssql.ErrNoRowsinstead. The driver said “end of result set” butdatabase/sqlsays “your query matched no rows.” Callers check one error regardless of whether Postgres, MySQL, or SQLite is behind the connection.
etcd’s clientv3 package
does the same translation in the reverse direction.
The client receives gRPC status codes from the server and maps them into plain
Go errors so that users never import google.golang.org/grpc/status. I
covered this in Wrapping a gRPC client in Go
.
Working examples for the HTTP version and the gRPC version are on GitHub, in the error-translation directory.