Build the error path in four deliberate layers: return errors explicitly, expose stable conditions with sentinels or typed errors, wrap only when the underlying cause is part of your API, and classify once at the HTTP boundary. Go’s standard http.Handler does not return an error, so a small application-owned adapter is the clean way to centralize status codes, public messages, and logging.
The error flow to aim for
A maintainable Go service keeps each decision at the layer that has enough information to make it. Low-level functions report failure as an ordinary return value. Domain or package code decides which conditions callers may rely on. Higher layers add context without accidentally exposing implementation details. The HTTP boundary translates the final condition into one response.
As an Amazon Associate I earn from qualifying purchases.
- Return an error value. Do not hide failure in a global variable, panic, or a string embedded in a successful result.
- Choose a public identity. Use a reused sentinel for a stable category, or a custom type when callers need structured fields.
- Wrap intentionally. Add operation context with
%wonly when callers should be able to inspect the wrapped cause. - Classify once. At the HTTP adapter, use
errors.Isorerrors.As, select an application-owned status and message, and write the response.
This separation prevents a handler from turning every internal failure into an ad hoc status code and prevents clients from depending on unstable database, filesystem, or vendor-specific text.
Recommended Free Tools
Errors are explicit return values
Go represents a failed operation with an additional return value, conventionally the last one. A successful call returns nil for that value; a failed call returns a non-nil error. The caller then chooses whether to add context, retry, translate the condition, or return it unchanged.
func loadUser(ctx context.Context, id string) (User, error) {
row, err := queryUser(ctx, id)
if err != nil {
return User{}, err
}
return decodeUser(row)
}
func service(ctx context.Context, id string) (User, error) {
user, err := loadUser(ctx, id)
if err != nil {
return User{}, fmt.Errorf(`load user %q: %w`, id, err)
}
return user, nil
}
The caller checks err before using the result. Returning the error preserves control flow: a transport layer can map a known condition to a client response, while an unexpected condition can be logged and treated as an internal failure.
Sentinels for stable, recognizable conditions
A sentinel is a package-level error value that names a condition other code may need to recognize. Reuse the same value; each call to errors.New creates a different instance, even when the text is identical.
package users
import `errors`
var ErrNotFound = errors.New(`user not found`)
func lookup(id string) (User, error) {
user, ok := inMemoryLookup(id)
if !ok {
return User{}, fmt.Errorf(`lookup %q: %w`, id, ErrNotFound)
}
return user, nil
}
Consumers should not compare errors with == after a function has added context. Use errors.Is, which searches the unwrap chain and still finds the sentinel.
user, err := lookup(id)
if err != nil {
if errors.Is(err, users.ErrNotFound) {
// The stable condition is known at this layer.
return User{}, ErrMissing
}
return User{}, err
}
Export a sentinel only when callers genuinely need that category. Once exported and documented, its meaning becomes part of your package contract. Keep internal implementation failures private when no caller should branch on them.
Typed errors when callers need data
A sentinel answers “which category is this?” A custom error type answers that question and carries fields such as a field name, an operation, or a retry interval. Define the type around information that is safe and useful to callers.
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return e.Field + `: ` + e.Reason
}
func validate(input Signup) error {
if input.Email == `` {
return &ValidationError{Field: `email`, Reason: `is required`}
}
return nil
}
Use errors.As to locate that type through any number of wrappers and retrieve its fields.
var validation *ValidationError
if errors.As(err, &validation) {
log.Printf(`invalid field %s`, validation.Field)
}
The target passed to errors.As must be a pointer to a variable whose type can hold the matching error. For a value type, use the corresponding value variable; for a pointer error type, use a pointer variable as shown. Newer Go toolchains also document errors.AsType as a typed alternative. Check the minimum Go version your module supports before adopting version-sensitive helpers.
Free tools Windows power users keep installed
One-click scans. No signup required.
What %w promises
fmt.Errorf with %w creates an unwrap relationship. It adds readable context while allowing errors.Is and errors.As to inspect the original cause.
if err != nil {
return fmt.Errorf(`read profile from cache: %w`, err)
}
Wrapping is not merely formatting; it is an API decision. If your package wraps a dependency’s sentinel or concrete type, callers can begin branching on that dependency. The wrapped identity is then part of your observable behavior and can constrain a future implementation change.
Expose the cause when it is intentional
Use %w when callers need to distinguish a condition, retry based on a cause, or extract documented fields. Document those conditions so users know which identities are stable.
Hide details when they are implementation-specific
If a database driver, file layout, or remote client is an internal choice, exposing its error type makes that choice a compatibility burden. Add text without an unwrap relationship, or translate the failure to a package-owned sentinel or type.
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
// The storage driver's error is deliberately hidden.
return fmt.Errorf(`load settings failed: %v`, err)
Using %v here means callers cannot discover the driver error with errors.Is or errors.As. That is correct only when hiding the dependency is intentional. Conversely, adding %w to every error “just in case” can expose more of your implementation than you want to support.
Why standard HTTP handlers need an adapter
The standard http.Handler interface has the shape ServeHTTP(ResponseWriter, *Request); it has no error return. http.HandlerFunc follows the same contract. A generic middleware cannot receive an error from a handler that has already returned only by completing ServeHTTP.
You can still centralize error handling by defining an application function type that does return an error, then adapting it once to http.Handler.
type AppHandler func(http.ResponseWriter, *http.Request) error
func (h AppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := h(w, r); err != nil {
writeError(w, r, err)
}
}
Every endpoint uses the application type. The adapter becomes the one place that classifies known conditions, logs unexpected failures, and writes a deliberate response.
Do these 3 things before closing this tab:
1Clear out junk files and repair common Windows errors2Scan for outdated or missing drivers - takes under a minute3Repair Windows errors before they cause bigger problemsRank #4
func getUser(w http.ResponseWriter, r *http.Request) error {
id := r.PathValue(`id`)
user, err := service(r.Context(), id)
if err != nil {
return fmt.Errorf(`get user %q: %w`, id, err)
}
return writeJSON(w, http.StatusOK, user)
}
mux := http.NewServeMux()
mux.Handle(`/users/`, AppHandler(getUser))
server := &http.Server{Handler: mux}
The exact route registration depends on how your router extracts parameters; the important property is that the endpoint returns an error instead of choosing its own status response.
Classify errors once at the boundary
There is no universal Go rule that maps every error to an HTTP status. Define categories that make sense for your application and map only those categories you have chosen to expose.
func writeError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, ErrNotFound):
writeProblem(w, http.StatusNotFound, `resource not found`)
case errors.Is(err, ErrConflict):
writeProblem(w, http.StatusConflict, `resource conflict`)
default:
slog.ErrorContext(r.Context(), `request failed`, `error`, err)
writeProblem(w, http.StatusInternalServerError, `internal server error`)
}
}
The public message is stable and intentionally narrower than the internal error. Do not send a wrapped database message, stack trace, file path, or dependency name to a client unless that detail is explicitly part of your API. Log the original error with request context where your operational policy calls for it.
Typed errors can provide safe response fields, but extract only fields that are documented as client-facing.
The Tool Desk
Outbyte PC Repair FREEClear out junk files and repair common Windows errorsFree Scan →Outbyte Driver Updater FREEFix the driver behind crashes, sound loss and screen glitchesFind Drivers →func writeError(w http.ResponseWriter, r *http.Request, err error) {
var validation *ValidationError
switch {
case errors.As(err, &validation):
writeJSON(w, http.StatusBadRequest, map[string]string{
`field`: validation.Field,
`message`: validation.Reason,
})
case errors.Is(err, ErrNotFound):
writeProblem(w, http.StatusNotFound, `resource not found`)
default:
slog.ErrorContext(r.Context(), `request failed`, `error`, err)
writeProblem(w, http.StatusInternalServerError, `internal server error`)
}
}
Order matters when categories overlap. Put the most specific, intentionally exposed type checks before a broad fallback. Keep the mapping function small enough that reviewers can see every public outcome.
Best Value
Sentinel, type, wrapping, and boundary decisions
| Need | Go mechanism | HTTP-boundary use |
|---|---|---|
| A stable category callers can recognize | Reused sentinel and errors.Is |
Map to an intentional status and public message |
| Structured information | Custom error type with errors.As or a supported errors.AsType |
Expose only documented, safe fields |
| Context while retaining inspectability | fmt.Errorf with %w |
Preserve classification through the adapter |
| Hide an implementation-specific cause | Context with %v, or translate to a package-owned error |
Use a stable public category or fallback response |
Response commitment is a hard boundary
An adapter cannot reliably replace a response after the handler has committed headers or written body bytes. ResponseWriter does not provide a general rollback transaction.
Adopt one of these policies explicitly:
- Return before writing. Perform validation and fallible work first, then write the response only after the operation succeeds.
- Buffer output. Build the complete representation in memory or another buffer, then copy it to the writer once no error remains.
- Document late-failure behavior. For streaming endpoints, define what a mid-stream failure means; the adapter may only be able to log it after bytes have left the process.
Do not promise that middleware can undo a partial response. If an endpoint writes a success header and later returns an error, the adapter may be limited to logging.
A practical implementation sequence
- List caller-visible conditions. Identify cases such as not found, invalid input, conflict, or permission failure that another package or the HTTP API must distinguish.
- Choose the representation. Use a sentinel for a category with no data; use a typed error when fields are required; keep purely internal failures unexported.
- Wrap at operation boundaries. Add identifiers and action names with
%wonly when preserving the cause is part of the contract. - Translate at abstraction boundaries. Convert dependency errors into your own category when callers should not depend on the dependency.
- Adapt once. Register
AppHandlervalues so endpoint code returns errors consistently. - Map in one function. Use
errors.Isfor sentinels,errors.Asfor types, and a deliberate fallback for everything else. - Test the chain. Verify classification through multiple wrappers and verify the exact status and public body produced by the adapter.
Testing the contract
Tests should assert behavior rather than error strings. A unit test for a wrapped sentinel proves that context does not break classification.
Windows Errors? Fix Them Before They Spread
Repair common Windows errors and clear accumulated junk for a smoother, more stable PC - no reinstall needed.Free scan · no reinstallCrashes, No Sound, or Screen Glitches?
Random freezes, missing sound and display glitches usually trace back to one bad driver. Find and replace yours safely.Free scan · under a minutefunc TestLookupNotFound(t *testing.T) {
_, err := lookup(`missing`)
if !errors.Is(err, ErrNotFound) {
t.Fatalf(`errors.Is() = false, want true: %v`, err)
}
}
For a typed error, assert both recognition and the fields that callers are promised.
func TestValidation(t *testing.T) {
err := validate(Signup{})
var ve *ValidationError
if !errors.As(err, &ve) {
t.Fatal(`errors.As() = false`)
}
if ve.Field != `email` {
t.Fatalf(`field = %q, want email`, ve.Field)
}
}
Use httptest.NewRecorder and a request to test the adapter’s status and body. Include an unexpected error case to ensure internal text is not leaked, and include a wrapped known error to prove the middleware sees through context.
Common mistakes and their fixes
- Comparing error strings. Text changes when context is added. Use
errors.Isorerrors.As. - Creating a new sentinel on every call. A fresh
errors.Newvalue cannot match the package sentinel. Declare and reuse one value. - Using
%vwhen callers need the cause. This destroys programmatic inspection. Use%wwhen exposure is intentional. - Wrapping every dependency error. That can make an implementation detail part of your API. Translate or hide it when appropriate.
- Returning raw internal errors to clients. Keep diagnostics in logs and send a stable public message.
- Letting each handler choose its own mapping. Centralize classification so status policy remains consistent.
- Assuming middleware can repair a written response. Enforce a no-write-before-success rule, buffer, or document streaming behavior.
Version and compatibility notes
The wrapping and inspection model discussed in the official Go guidance dates from Go 1.13 and remains the basis for errors.Is, errors.As, and %w. The errors package documentation is updated independently of your application, and newer helpers such as errors.AsType may not exist in the minimum toolchain used by an older module. Check go.mod, your CI toolchain, and the target deployment before using newer syntax.
The single error-returning middleware shown here is an application convention built around the standard handler signature, not a feature provided by net/http. You own its response schema, logging policy, status mapping, and rules for late failures.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

