Error handling in Go is simple. Sometimes too simple. A fmt.Errorf("pq: no rows in result set")
bubbling up to an HTTP handler, and suddenly SQL internals are exposed in a 500 response.
No exotic vulnerability required — just an err.Error() in the wrong place.
This problem is systemic. It doesn't stem from a lack of discipline, but from the absence of
a boundary in the type system. Nothing in the error interface distinguishes
what is safe to expose from what is not.
The concrete problem
Here is the classic leak pattern. A handler that serves err.Error() directly
in its JSON response, and a repository layer that wraps errors with technical context:
// HTTP handler
func (h *Handler) GetOrder(c echo.Context) error {
order, err := h.service.FindByID(ctx, id)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": err.Error(), // ← LEAK
})
}
return c.JSON(http.StatusOK, order)
}
// repository layer
func (r *Repo) FindByID(ctx context.Context, id string) (*Order, error) {
var o Order
err := r.db.GetContext(ctx, &o, "SELECT * FROM orders WHERE id = $1", id)
if err != nil {
return nil, fmt.Errorf("orders.FindByID: %w", err) // standard wrapping
}
return &o, nil
}
Response received by the client:
{"error": "orders.FindByID: pq: no rows in result set"}
The table name, PostgreSQL driver, and nature of the SQL error are publicly exposed. Not catastrophic in isolation — but exactly the kind of information an attacker uses to refine an injection or target an attack surface.
What existing solutions offer (and their limits)
| Solution | Approach | Limitation |
|---|---|---|
cockroachdb/errors |
Exhaustive package, stack traces, rich wrapping | ~15k lines, 8 sub-packages. Disproportionate for a business microservice. |
| upspin pattern (Rob Pike) | Error type with Kind, Op, Err |
Pre-Go 1.13, no slog, and errors.Is() traverses the full chain. |
| hashicorp pattern | UserError interface with GetMessage() |
Relies on developer discipline, not the type system. |
The real question: can we design a type where err.Error() always
returns something safe, without any convention to remember?
The safeerr design
Four principles structure the package:
- Safe by default:
Error()returns only the user-facing message. Even an accidentalhttp.Error(w, err.Error(), 500)leaks nothing. Unwrap()exposes the cause: the technical cause is accessible via theerrors.Is/Aschain for logging and debugging, but never throughError().slog.LogValuer: technical details (code, cause) are exposed only through structured logs.- Immutable builder pattern:
WithMsg(),WithCause()always return a new instance — sentinels are never mutated.
The sentinels
var (
ErrNotFound = safeerr.New("NOT_FOUND", "not found")
ErrUnauthorized = safeerr.New("UNAUTHORIZED", "unauthorized")
ErrInvalidInput = safeerr.New("INVALID_INPUT", "invalid input")
ErrInvalidState = safeerr.New("INVALID_STATE", "invalid state")
ErrInternal = safeerr.New("INTERNAL", "internal error")
)
New takes two fields: the machine code and the user-facing message.
HTTP status mapping is delegated to the handler layer — the separation is explicit.
The immutable builder pattern
// Custom user-facing message
err := ErrNotFound.WithMsg("user 42 not found")
// Formatted user-facing message
err = ErrNotFound.WithMsgf("user %d not found", id)
// Technical cause for logs only
err = ErrNotFound.WithCause(dbErr)
// Formatted technical cause
err = ErrNotFound.WithCausef("query %s: %w", table, dbErr)
// Both — chaining is immutable, each call returns a new instance
err = ErrNotFound.WithMsg("user 42 not found").WithCause(dbErr)
Each call creates a new instance. Sentinels declared as var are never modified —
no risk of shared mutation across goroutines.
errors.Is by code
func (e *Error) Is(target error) bool {
t, ok := target.(*Error)
return ok && e.code == t.code
}
// In use
err := ErrNotFound.WithMsg("user 42 not found")
errors.Is(err, ErrNotFound) // true — same code, regardless of message or cause
Matching is by code, not by pointer. Two instances derived from the same sentinel always match, regardless of any customisation applied.
slog.LogValuer
func (e *Error) LogValue() slog.Value {
attrs := []slog.Attr{slog.String("code", e.code), slog.String("msg", e.msg)}
if e.cause != nil {
attrs = append(attrs, slog.Any("cause", e.cause))
}
return slog.GroupValue(attrs...)
}
Resulting log:
level=ERROR msg="order processing failed" err.code=NOT_FOUND err.msg="order not found" err.cause="pq: no rows in result set"
slog.Any instead of slog.String: if the cause itself implements
slog.LogValuer, it structures recursively in the logs.
Technical details never reach Error().
Wrap for service boundaries
// Add context to the cause chain without changing the sentinel
return safeerr.Wrap(repoErr, "UserService.Get")
// Formatted equivalent
return safeerr.Wrapf(repoErr, "UserService.Get user=%s", userID)
Wrap handles two cases: if err is already a *Error,
it preserves the code and enriches the cause. If it's a stdlib error, it delegates to
fmt.Errorf. One call handles both.
Centralized HTTP mapping
func Error(c echo.Context, err error) error {
var appErr *safeerr.Error
if errors.As(err, &appErr) {
return c.JSON(httpStatus(appErr.Code()), map[string]string{
"error": appErr.Error(),
})
}
slog.Error("unhandled error reaching handler", "err", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal error"})
}
A single place in the codebase knows how to translate an error into an HTTP response.
httpStatus() maps machine codes to HTTP status codes — separate from the
error definitions themselves.
Before / after
Before — technical errors bubble up raw to the handler:
// service — raw technical error propagated
func (s *Service) ProcessOrder(ctx context.Context, cmd ProcessOrderCmd) error {
order, err := s.repo.FindOrder(ctx, cmd.OrderID)
if err != nil {
return fmt.Errorf("FindOrder: %w", err)
}
if order.ContractRef == "" {
return errors.New("order has no contract reference")
}
return nil
}
After — explicit conversion at the service boundary:
// service — explicit conversion at the boundary
func (s *Service) ProcessOrder(ctx context.Context, cmd ProcessOrderCmd) error {
order, err := s.repo.FindOrder(ctx, cmd.OrderID)
if err != nil {
return ErrNotFound.WithMsg("order not found").WithCause(err)
}
if order.ContractRef == "" {
return safeerr.ErrInvalidState.WithMsg("order has no contract reference")
}
return nil
}
// handler
func (h *Handler) ProcessOrder(c echo.Context) error {
if err := h.service.ProcessOrder(ctx, cmd); err != nil {
return render.Error(c, err)
}
return c.JSON(http.StatusOK, nil)
}
HTTP response: {"error": "order not found"} — the SQL cause stays in the logs,
never in the response.
The full package (~70 lines)
package safeerr
import (
"errors"
"fmt"
"log/slog"
)
// Error() returns only the user-facing message — safe by default.
type Error struct {
code string
msg string
cause error
}
func New(code, msg string) *Error {
return &Error{code: code, msg: msg}
}
func (e *Error) Error() string { return e.msg }
func (e *Error) Code() string { return e.code }
func (e *Error) Unwrap() error { return e.cause }
func (e *Error) Is(target error) bool {
t, ok := target.(*Error)
return ok && e.code == t.code
}
func (e *Error) LogValue() slog.Value {
attrs := []slog.Attr{slog.String("code", e.code), slog.String("msg", e.msg)}
if e.cause != nil {
attrs = append(attrs, slog.Any("cause", e.cause))
}
return slog.GroupValue(attrs...)
}
func (e *Error) WithMsg(msg string) *Error {
return &Error{code: e.code, msg: msg, cause: e.cause}
}
func (e *Error) WithMsgf(format string, args ...any) *Error {
return e.WithMsg(fmt.Sprintf(format, args...))
}
func (e *Error) WithCause(cause error) *Error {
return &Error{code: e.code, msg: e.msg, cause: cause}
}
func (e *Error) WithCausef(format string, args ...any) *Error {
return e.WithCause(fmt.Errorf(format, args...))
}
func Wrap(err error, msg string) error {
if err == nil {
return nil
}
if e, ok := err.(*Error); ok {
if inner := e.Unwrap(); inner != nil {
return e.WithCause(fmt.Errorf("%s: %w", msg, inner))
}
return e.WithCause(errors.New(msg))
}
return fmt.Errorf("%s: %w", msg, err)
}
func Wrapf(err error, format string, args ...any) error {
return Wrap(err, fmt.Sprintf(format, args...))
}
Honest trade-offs
-
No stack traces.
Errordoes not capture the goroutine stack. Structured logs with business context compensate in most cases, but for deep post-mortem debugging this is a real limitation. -
Leaking is still possible if you're not careful.
ErrNotFound.WithMsg(err.Error())breaks the safety guarantee — the technical message becomes user-facing. The API prevents accidental leaks viaError(), not deliberate ones through carelessness. Discipline is still required at this specific point. -
The conversion cost. Every technical error must be converted to a
*Errorat the service boundary. This is intentional friction — on a project with a hundred error sites, that's code to write and maintain. -
Unwrap()exposes the cause to the full chain.errors.Is(err, sql.ErrNoRows)works from an upper layer — which means a handler layer can couple on a repo implementation detail if it chooses to. This is a conscious choice: the cause is accessible for code (logs, tests), but never viaError(). The sentinel taxonomy should be expressive enough that this coupling is never needed.
Conclusion
The real value of this package is not in the 70 lines. It's in the boundary it makes explicit between what is safe to expose and what is not. That boundary already existed in any well-designed application — but it relied on discipline, on documented conventions, on careful reviews.
Moving this constraint into the type system has a cost: explicit conversion at every boundary.
In return: an accidental err.Error() in an HTTP response never leaks anything,
regardless of who wrote the code.
For an internal library shared across multiple microservices in the same stack, it's an investment that pays off quickly.