Back to blog

Sunday, October 25, 2026

Why Does Error Handling In Go Web Apps Feel So Awkward?

Title card for 'Why Does Error Handling In Go Web Apps Feel So Awkward?' with a short code example

Introduction

Error handling in Go web apps feels awkward because a handler returns no error, while a web failure needs three different things at once: a status code, a message that is safe to show a client, and a detailed record for the server log. Go gives you the error value, and leaves the translation from that value to a response entirely to you. The fix is to move that translation to one place, at the handler boundary, and keep every handler to one rule: return an error, or write a success, never both.

Before You Start

  • Handler: a function with the signature func(w http.ResponseWriter, r *http.Request). The net/http package calls it through the Handler interface, whose ServeHTTP method returns nothing, so a handler cannot return an error to the server.
  • ResponseWriter: the interface a handler uses to build a response, with Header, Write, and WriteHeader. Once the status is written, it cannot be changed.
  • Status code: a number in a response, such as 400, 404, or 500, that tells the client what kind of result it received. Go provides constants for these numbers, such as http.StatusNotFound, but Go provides no mapping from an error value to a status code. That mapping is an application decision, and this article states its mapping as a choice, not as a Go standard.
  • Error value: a value of the error interface, which has a single method, Error() string. Functions return it as a second result, and callers check it with if err != nil.
  • Sentinel error: a package level error value for a known condition, such as a user that does not exist, checked with errors.Is. Wrapping and sentinel errors in general are covered in the companion article on verbose error handling, linked in Related Posts, and are summarised in one paragraph below rather than repeated here.
  • Central handler: a wrapper type around a handler function that returns an error. Its ServeHTTP method calls the handler, and if the handler returns an error, the wrapper writes the error response. The Go blog on error handling shows this shape with an appHandler type, and this article adapts it for a JSON API.
  • Public message: the message sent to the client. It must be safe to show anyone, with no database address, file path, query, or internal reason.
  • Internal detail: the full error chain, kept for the server log only. It explains what failed and where, and it never reaches the client.
  • Request context: the context carried by r.Context(). It is cancelled when the client disconnects, so a handler that keeps working after cancellation is doing work for a client that is gone.

Prerequisites

  • Go 1.20 or later, because the article uses only standard library packages that are stable in that version.
  • A terminal, and a text editor.
  • No third party packages are required for the complete example.
go version

Requires: Go 1.20 or later.

Project Layout

web-errors/
├── go.mod        # module declaration, Go version
├── main.go       # store, handler, central handler, and server setup
└── main_test.go  # success, not found, internal, method, and id tests
  • go.mod: declares the module and the minimum Go version.
  • main.go: holds the whole boundary, so the translation from error to response can be read in one file.
  • main_test.go: holds tests that prove the client sees a safe message and the correct status, even when the server error is detailed.

The General Error Rules, In One Paragraph

The companion article covers the general rules in full: functions return errors as values, fmt.Errorf with %w adds context while preserving the chain, errors.Is matches a sentinel through wrapping, and errors.As extracts a typed error from a chain. This article assumes those rules and uses them only at the boundary. A service function wraps a low level failure with context, the handler translates a known failure into a public error, and the central handler turns that public error into a response. Nothing in this article changes those rules. It only decides where each rule is applied in a web app.

Why The Handler Shape Makes Errors Awkward

A normal Go function returns (result, error), and the caller decides what to do with the error. A handler returns nothing. The signature func(w http.ResponseWriter, r *http.Request) has no error result, so there is nowhere for a failure to go except into the response itself, written by hand, inside the handler. That single fact creates the repetition that feels awkward.

First, every failure needs a status, and the handler must choose it. A missing user is 404, bad input is 400, a database failure is 500, but Go does not choose for you. The same database failure can be a 500 in one handler and, if a developer copies the wrong line, a 400 in another, and the client cannot tell which failures are its fault and which are yours.

Second, every failure needs two messages, not one. The client needs a short, safe message. The log needs the full chain, with the operation, the id, and the low level cause. A handler that sends err.Error() to the client gives the client the log message, and a handler that logs only the public message gives the operator nothing to debug with. Writing both, correctly, in every handler, is the work that gets skipped under pressure.

Third, a handler has no return value to signal that it failed, so a handler that writes a partial success and then fails has already committed to a response. The client sees a 200 status with a broken body, or a body that stops halfway, and no error response can replace it. The handler must therefore decide, before writing anything, whether the request will succeed.

Fourth, a handler runs for one request, but error policy belongs to the whole app. Status mapping, error body shape, logging, and recovery from a panic are app level decisions. When each handler makes them separately, the app answers the same failure differently on different routes, and a change in policy means editing every handler.

The One Shot Response Rule

A response in Go is written once, and the first write fixes the status. The net/http documentation states the rule: if WriteHeader has not been called, the first call to Write triggers an implicit WriteHeader(http.StatusOK). Explicit calls to WriteHeader are mainly used to send error codes. After that point, changing the header has no effect, and a second status is ignored.

That rule has three consequences for error handling:

  • Encode before you write: build the success body in a buffer first, and only call WriteHeader and Write after the encode succeeds. An encode failure before any write can still become a 500 response. An encode failure after a write cannot.
  • Read before you write: handlers should read any needed request body data before writing the response, because writing can make the body unavailable, for HTTP/1.x requests in particular.
  • Do not use the writer after the handler returns: a ResponseWriter may not be used after ServeHTTP has returned. A goroutine that writes a late error after the handler returned is writing to a finished response.

The practical rule for a handler is strict: return an error, or write a success, never both, and never write a success and then discover a failure. The complete example follows that rule by encoding to a buffer before any write.

Status Mapping Is An Application Decision

Go defines status constants, and it does not define which error maps to which status. The table below is the mapping used in the complete example. It is a choice for this app, not a rule from Go, and a different app can choose differently, provided it chooses once and applies the choice everywhere.

FailureStatus in this examplePublic message in this example
User id missing or malformed400user id is missing
Method other than GET405method not allowed
User not found404user not found
Database or encode failure500user could not be loaded, or user could not be shown
Client disconnectedNo response writtenNone, the client is gone

A status mapping must be consistent, because clients branch on it. A client that retries on 500 and stops on 400 will retry forever if a database failure is reported as 400, and will give up on a fixable input error if bad input is reported as 500. The mapping is therefore part of the API contract, and it belongs in one function, not scattered across handlers.

Public Message Versus Internal Detail

A leaked internal detail is a message that tells a stranger more than the stranger needs. A database error that says query users: connection refused tells a client that the app uses a database, that the connection failed, and, in other cases, the host, the query, or the file path. A client needs none of that to handle a failure. The client needs to know that the user could not be loaded, and whether to retry.

The split is therefore:

  • Client receives: status, and a short public message, in a stable shape. In the complete example, that shape is a JSON object with one field, {"error": "user not found"}.
  • Server log receives: method, path, status, and the full error chain, including the wrapped context and the low level cause. In the complete example, a 500 log line carries the full chain, while a 4xx log line carries method, path, and status only, because a 4xx failure is usually a client problem, not a server problem to alert on.

A public message must be written by the app, not copied from the error. If the public message is err.Error(), then every new wrapped context, every database driver message, and every file path becomes part of the API. A public message that is a fixed string, chosen at the boundary, stays stable when the internals change.

Logging Versus Returning

Logging and returning are two different outputs for two different readers, and they must not be confused. Returning sends a response to the client. Logging records a fact for the operator. A handler that does only one of them is incomplete: a client with no response waits until timeout, and an operator with no log cannot explain a failure.

The central handler in the complete example separates them:

  • The handler returns an error value, with a public message and the wrapped internal error.
  • The central handler logs the error, with method and path, and the full chain for a 500.
  • The central handler writes the public message to the client, with the mapped status.

Log levels follow the same split. A 4xx error is an expected client outcome, such as a missing user, and belongs in an info level record. A 5xx error is an unexpected server failure, and belongs in an error level record that can alert. A client disconnect is neither: the client left, so writing a 500 response to a closed connection helps nobody, and logging it as a server error creates false alarms. The complete example returns without writing when the request context is cancelled.

The Central Handler Pattern

The central handler pattern removes the repetition by giving handlers an error return again. The Go blog on error handling introduces it as an appHandler type, a function type with a ServeHTTP method. This article uses the same shape, with two changes for a JSON API: the handler returns a plain error, not a custom struct, and the central handler translates that error into a public error before writing.

type appHandler func(w http.ResponseWriter, r *http.Request) error

func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if err := fn(w, r); err != nil {
        writeError(w, r, err)
    }
}

A handler written in this shape is short, because it contains no status, no JSON error body, and no logging:

func (s *server) getUser(w http.ResponseWriter, r *http.Request) error {
    id := strings.TrimPrefix(r.URL.Path, "/users/")
    if id == "" {
        return &APIError{Status: http.StatusBadRequest, Public: "user id is missing"}
    }
    user, err := s.store.Get(r.Context(), id)
    if err != nil {
        if errors.Is(err, ErrUserNotFound) {
            return &APIError{Status: http.StatusNotFound, Public: "user not found", Err: err}
        }
        return &APIError{Status: http.StatusInternalServerError, Public: "user could not be loaded", Err: err}
    }
    return writeJSON(w, http.StatusOK, user)
}

The translation function, writeError, is the only place that knows the error body shape, the logging rule, and the fallback for an unknown error:

func writeError(w http.ResponseWriter, r *http.Request, err error) {
    if r.Context().Err() != nil {
        return
    }
    var apiErr *APIError
    status := http.StatusInternalServerError
    public := "internal error"
    if errors.As(err, &apiErr) {
        status = apiErr.Status
        public = apiErr.Public
    }
    if status >= 500 {
        log.Printf("%s %s -> %d: %v", r.Method, r.URL.Path, status, err)
    } else {
        log.Printf("%s %s -> %d", r.Method, r.URL.Path, status)
    }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    _ = json.NewEncoder(w).Encode(map[string]string{"error": public})
}

Three details in that function matter. First, an error that is not an APIError falls back to 500 with a generic message, so no handler can leak a detail by forgetting to translate. Second, the client receives only the public message, while the log receives the full chain for a 500. Third, a cancelled request context returns before writing, because the client is gone.

A recovery wrapper belongs outside this pattern, not inside it. A panic is not an error return, and a central handler that only handles returned errors will not catch one. A middleware that recovers, logs the panic, and writes a 500 is a separate layer, and it must wrap the central handler, so that a panic in any handler becomes a 500 response instead of a crashed connection. Panic versus returning an error is covered in the companion articles linked in Related Posts.

Buffer First, Then Write

The success path needs the same care as the error path. Encoding a value directly to the ResponseWriter writes the 200 status as soon as the first bytes are written. If encoding then fails halfway, the response is already a 200 with a partial body, and no error response can replace it.

The fix is to encode to a buffer, check the encode error, and only then write:

func writeJSON(w http.ResponseWriter, status int, value any) error {
    var buf bytes.Buffer
    if err := json.NewEncoder(&buf).Encode(value); err != nil {
        return &APIError{Status: http.StatusInternalServerError, Public: "user could not be shown", Err: err}
    }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    _, _ = w.Write(buf.Bytes())
    return nil
}

After WriteHeader, a write failure cannot be turned into an error response, so the example returns nil after writing. That is a deliberate consequence of the one shot rule, not a missing check: the response is already committed, and the only honest action left is to stop.

Request Context And Client Disconnects

A web handler runs while a client waits, and the client can leave. The request context is cancelled when the client disconnects, and a handler that ignores it keeps querying, encoding, and logging for a request that no longer exists. In the complete example, the store checks the context before doing work, and the central handler checks it before writing an error response.

A cancelled request is not a server failure. It must not become a 500 in the log, and it must not become a 500 response to a client that is gone. The handler returns the context error, the central handler sees the cancelled context, and it returns without writing. That distinction keeps disconnects out of error alerts, and keeps the 500 count for failures the server actually owns.

Design Decisions And Tradeoffs

OptionUse whenCost
Handler writes its own error responseA single route, with no shared policyEvery handler repeats status, body, and logging, and they drift apart
Central handler with error returnMore than one route, or a shared error shapeHandlers must follow one rule: return an error, or write a success, never both
Service returns HTTP status directlyA tiny app where the service is only used by HTTPThe service now knows about HTTP, and cannot be reused by a CLI or a worker without carrying status with it
Service returns domain errors, handler translatesA service used by more than one entry pointOne translation step per handler, which the central handler then turns into a response
Public message copied from the errorNever, for a client facing responseInternal details leak, and the API message changes whenever internals change
Fixed public message, full chain in the logEvery client facing errorThe public message must be chosen per failure, and the log must carry the chain
Encode directly to the writerA response that cannot fail to encode, such as a fixed stringA partial 200 body if encoding fails halfway
Buffer first, then writeAny JSON responseOne buffer allocation per response, and the body is held in memory before writing
Log every error at the same levelA tiny app with one operatorClient disconnects and missing users look like server failures in alerts
Log 4xx as info, 5xx as error, disconnects separatelyAny app with alertsThe central handler must classify, and the classification must stay in one place

A design in this article has a deliberate compromise, stated openly. The complete example uses an in memory store, not a database, so the database failure is simulated with a flag. That keeps the example runnable with no setup, at the cost of realism: a real store failure would carry a driver error, a timeout, or a connection error, and the handler translation would be the same, but the log line would be longer. The boundary rule itself is not compromised: the client still sees only the public message, and the log still sees the full chain.

Complete Example

The complete example is a user lookup API with one route, GET /users/<id>. The store holds two users. A missing user becomes 404, a simulated store failure becomes 500 with a generic client message, a wrong method becomes 405, and a missing id becomes 400. The route uses a plain /users/ prefix, not a method and wildcard pattern, so the example stays on the Go 1.20 baseline in go.mod.

// go.mod
module weberrors

go 1.20
// main.go
package main

import (
    "bytes"
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "log"
    "net/http"
    "strings"
)

type User struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

var ErrUserNotFound = errors.New("user not found")

type APIError struct {
    Status int
    Public string
    Err    error
}

func (e *APIError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("%s: %v", e.Public, e.Err)
    }
    return e.Public
}

func (e *APIError) Unwrap() error {
    return e.Err
}

type store struct {
    users map[string]User
    fail  bool
}

func (s *store) Get(ctx context.Context, id string) (*User, error) {
    if err := ctx.Err(); err != nil {
        return nil, err
    }
    if s.fail {
        return nil, fmt.Errorf("query users: connection refused")
    }
    user, ok := s.users[id]
    if !ok {
        return nil, fmt.Errorf("lookup user %s: %w", id, ErrUserNotFound)
    }
    return &user, nil
}

type server struct {
    store *store
}

type appHandler func(w http.ResponseWriter, r *http.Request) error

func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if err := fn(w, r); err != nil {
        writeError(w, r, err)
    }
}

func writeError(w http.ResponseWriter, r *http.Request, err error) {
    if r.Context().Err() != nil {
        return
    }
    var apiErr *APIError
    status := http.StatusInternalServerError
    public := "internal error"
    if errors.As(err, &apiErr) {
        status = apiErr.Status
        public = apiErr.Public
    }
    if status >= 500 {
        log.Printf("%s %s -> %d: %v", r.Method, r.URL.Path, status, err)
    } else {
        log.Printf("%s %s -> %d", r.Method, r.URL.Path, status)
    }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    _ = json.NewEncoder(w).Encode(map[string]string{"error": public})
}

func writeJSON(w http.ResponseWriter, status int, value any) error {
    var buf bytes.Buffer
    if err := json.NewEncoder(&buf).Encode(value); err != nil {
        return &APIError{Status: http.StatusInternalServerError, Public: "user could not be shown", Err: err}
    }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    _, _ = w.Write(buf.Bytes())
    return nil
}

func (s *server) getUser(w http.ResponseWriter, r *http.Request) error {
    if r.Method != http.MethodGet {
        return &APIError{Status: http.StatusMethodNotAllowed, Public: "method not allowed"}
    }
    id := strings.TrimPrefix(r.URL.Path, "/users/")
    if id == "" || strings.Contains(id, "/") {
        return &APIError{Status: http.StatusBadRequest, Public: "user id is missing"}
    }
    user, err := s.store.Get(r.Context(), id)
    if err != nil {
        if errors.Is(err, ErrUserNotFound) {
            return &APIError{Status: http.StatusNotFound, Public: "user not found", Err: err}
        }
        return &APIError{Status: http.StatusInternalServerError, Public: "user could not be loaded", Err: err}
    }
    return writeJSON(w, http.StatusOK, user)
}

func main() {
    srv := &server{store: &store{users: map[string]User{
        "ada":   {ID: "ada", Name: "Ada"},
        "grace": {ID: "grace", Name: "Grace"},
    }}}
    mux := http.NewServeMux()
    mux.Handle("/users/", appHandler(srv.getUser))
    log.Fatal(http.ListenAndServe(":8080", mux))
}

Commands to run it, from the web-errors folder:

go run .

Expected behaviour, checked by the tests below:

GET /users/ada    -> 200, {"id":"ada","name":"Ada"}
GET /users/nobody -> 404, {"error":"user not found"}
GET /users/       -> 400, {"error":"user id is missing"}
POST /users/ada   -> 405, {"error":"method not allowed"}

Why this program answers correctly, in order:

  • getUser checks method and id before touching the store, so a bad request never reaches the store.
  • The store returns a wrapped sentinel for a missing user, and a plain wrapped error for a simulated failure, so the handler can tell the two apart with errors.Is.
  • The handler translates, but never writes an error response itself. The central handler writes, once, from one function.
  • The success path encodes to a buffer first, so an encode failure can still become a 500, and a success write happens only after encoding succeeds.

Testing Handlers

Test a handler through the central handler, not through the inner function alone. Testing only the inner function checks the translation, but it does not check the status, the body shape, or the leak rule. The httptest package provides a recorder and a request, so the test sees exactly what a client would see.

// main_test.go
package main

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"
)

func testServer(fail bool) *server {
    return &server{store: &store{users: map[string]User{
        "ada": {ID: "ada", Name: "Ada"},
    }, fail: fail}}
}

func call(t *testing.T, srv *server, method string, path string) *httptest.ResponseRecorder {
    t.Helper()
    req := httptest.NewRequest(method, path, nil)
    rec := httptest.NewRecorder()
    appHandler(srv.getUser).ServeHTTP(rec, req)
    return rec
}

func errorBody(t *testing.T, raw string) string {
    t.Helper()
    var body map[string]string
    if err := json.NewDecoder(strings.NewReader(raw)).Decode(&body); err != nil {
        t.Fatalf("decode error body: %v", err)
    }
    return body["error"]
}

func TestGetUserSuccess(t *testing.T) {
    rec := call(t, testServer(false), http.MethodGet, "/users/ada")
    if rec.Code != http.StatusOK {
        t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
    }
    var user User
    if err := json.NewDecoder(rec.Body).Decode(&user); err != nil {
        t.Fatalf("decode user: %v", err)
    }
    if user.Name != "Ada" {
        t.Errorf("name = %q, want %q", user.Name, "Ada")
    }
}

func TestGetUserNotFound(t *testing.T) {
    rec := call(t, testServer(false), http.MethodGet, "/users/nobody")
    if rec.Code != http.StatusNotFound {
        t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
    }
    raw := rec.Body.String()
    if got := errorBody(t, raw); got != "user not found" {
        t.Errorf("error = %q, want %q", got, "user not found")
    }
    if strings.Contains(raw, "lookup user") {
        t.Errorf("response leaks internal detail: %q", raw)
    }
}

func TestGetUserInternal(t *testing.T) {
    rec := call(t, testServer(true), http.MethodGet, "/users/ada")
    if rec.Code != http.StatusInternalServerError {
        t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
    }
    raw := rec.Body.String()
    if got := errorBody(t, raw); got != "user could not be loaded" {
        t.Errorf("error = %q, want %q", got, "user could not be loaded")
    }
    if strings.Contains(raw, "connection refused") {
        t.Errorf("response leaks internal detail: %q", raw)
    }
}

func TestGetUserMethodNotAllowed(t *testing.T) {
    rec := call(t, testServer(false), http.MethodPost, "/users/ada")
    if rec.Code != http.StatusMethodNotAllowed {
        t.Fatalf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed)
    }
}

func TestGetUserMissingID(t *testing.T) {
    rec := call(t, testServer(false), http.MethodGet, "/users/")
    if rec.Code != http.StatusBadRequest {
        t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
    }
}

Commands to run the tests and the static check:

go vet ./...
go test ./...

Expected output for the tests is a pass for every test, including the two leak tests, because the client body carries only the public message. go vet reports suspicious constructs. It does not prove that a status mapping is correct, or that a public message is safe, so do not treat a clean vet run as proof of either. The status and body assertions in the tests are the proof for this example.

Common Mistakes

  • Sending the error string to the client: a handler that writes err.Error() to the client sends the log message, with database, file, and query details, to anyone who asks. The fix is a fixed public message, chosen at the boundary, with the full chain in the log only.
  • Mapping every failure to 500: a handler that answers every error with 500 hides client errors from the client, and hides server errors from the operator in a different way, because nothing distinguishes them. The fix is one mapping function, with 4xx for client failures and 5xx for server failures.
  • Mapping a server failure to 400: a handler that answers a database failure with 400 tells the client its request was wrong, and tells a retrying client to stop, when the server is at fault. The fix is to map unknown errors to 500 by default, and only map known client failures to 4xx.
  • Writing a success, then failing: a handler that encodes directly to the writer, then hits an error, has already sent a 200 with a partial body. The fix is to encode to a buffer first, and write only after encoding succeeds.
  • Writing an error after a success: a handler that writes a success body and then returns an error asks the central handler to write a second response, which the one shot rule ignores. The fix is the strict rule: return an error, or write a success, never both.
  • Logging only the public message: a log line that says only user could not be loaded cannot explain which user, which query, or which cause failed. The fix is to log the full chain for a 500, with method and path, while the client keeps the public message.
  • Logging a disconnect as a server error: a handler that logs a cancelled request context as a 500 fills alerts with clients that left. The fix is to check the request context before writing and before logging a 500, and to treat a disconnect as a separate outcome.
  • Letting a panic reach the client: a handler that panics, with no recovery wrapper, drops the connection instead of answering 500. The fix is a recovery middleware outside the central handler, which logs the panic and writes a generic 500.
  • Putting HTTP status in the service layer: a service that returns a status code cannot be reused by a CLI, a worker, or a test without carrying HTTP with it. The fix is to return domain errors from the service, and translate to status only at the handler boundary.
  • Changing the error body shape per handler: one handler that returns {"error": "..."}, another that returns {"message": "..."}, and a third that returns plain text, forces every client to handle three shapes. The fix is one error body shape, written by one function.
  • Reading the body after writing: a handler that writes a response and then tries to read the request body may find the body unavailable, for HTTP/1.x requests in particular. The fix is to read all needed body data before any write.

Production Notes

  • Error body shape: keep one shape across every route, and document it as part of the API. A client that parses error on one route and message on another is a client that will break on the next route added.
  • Logging: log method, path, status, and the full chain for a 500. Do not log request bodies, tokens, or passwords, even when logging a failure, because a log that holds secrets becomes a second place to protect.
  • Alerts: alert on 5xx rate, not on 4xx rate, and not on disconnects. A missing user is a normal outcome, and a disconnect is a client choice. Alerting on either buries the server failures that need action.
  • Timeouts: a handler that waits on a slow store holds a connection, and a client that waits holds a slot. Pass the request context to the store, so a client disconnect or a server timeout cancels the work, instead of letting it run after the response is gone.
  • Recovery: wrap the central handler with a recovery middleware in production, so a panic becomes a logged 500 with a generic message, not a dropped connection. A panic is a bug, and it belongs in the log, not in the client response.
  • Deployment: the example needs Go 1.20 or later. Pin that version in go.mod, run go vet ./... and go test ./... in continuous integration, and do not deploy a change that fails the status or leak tests. A vet pass alone is not enough for a web boundary, because vet does not check that a public message is safe.

Decision Guide

  • When a handler needs to report a failure: return an error, and let the central handler write the response. Do not write the error response inside the handler.
  • When the service knows why it failed, but not what HTTP means: return a domain error, such as a sentinel, and translate it in the handler. Keep status out of the service.
  • When a failure is unknown: map it to 500 with a generic public message, and log the full chain. An unknown failure is a server failure until proven otherwise.
  • When a failure is a client problem: map it to a 4xx status with a public message the client can act on, such as a missing id or a bad method.
  • When a response body must be JSON: encode to a buffer first, check the encode error, and only then set headers and write.
  • When a client disconnects: return without writing, and do not count the request as a server error. The client is gone, and no response can reach it.
  • When a handler panics: let a recovery middleware outside the central handler turn it into a logged 500. A panic is not an error return, and the central handler alone cannot catch it.

Conclusion

Go web error handling feels awkward because the handler signature removes the one tool Go normally gives you, an error return, at exactly the place where a failure needs three answers: a status, a safe message, and a log record. The awkwardness is not in the error value itself. It is in doing that translation by hand, in every handler, with no place that owns it.

Summary Of Key Points

  • Handlers return no error: the ServeHTTP signature has no error result, so a failure must be turned into a response by hand, unless a wrapper restores the error return.
  • Status mapping is a choice: Go provides status constants, not a mapping from errors to statuses, so the app must choose one mapping and apply it everywhere.
  • Two audiences, two messages: the client gets a short, fixed public message, and the log gets the full chain. Sending one to the other audience is the leak, in either direction.
  • Responses are one shot: the first write fixes the status, so encode to a buffer first, and return an error or write a success, never both.
  • One boundary owns the policy: a central handler writes the error body, logs the error, and falls back to 500 for unknown errors, so no handler can drift from the policy.
  • Disconnects are not server errors: a cancelled request context means the client left, so the handler returns without writing and without a 500 alert.

Move the translation to the boundary, keep the service free of HTTP, and the awkwardness shrinks to one function that is straightforward to test: the function that turns an error into a response.

Additional Resources

  • Go Packages: net/http package - Handler, ResponseWriter, WriteHeader, and the one shot response rules
  • Go Packages: errors package - New, Is, As, Unwrap, and Join for error chains
  • Go Blog: Error handling and Go - the error interface, and the appHandler central handler shape
  • Go Documentation: Effective Go - errors, and the convention of returning errors as values

References

  1. net/http package. Go team, Go Packages. https://pkg.go.dev/net/http. Accessed 2026-10-25.
  2. errors package. Go team, Go Packages. https://pkg.go.dev/errors. Accessed 2026-10-25.
  3. Error handling and Go. Go team, Go Blog. https://go.dev/blog/error-handling-and-go. Accessed 2026-10-25.
  4. Effective Go. Go team, Go Documentation. https://go.dev/doc/effective_go. Accessed 2026-10-25.