Go errors: sentinel errors, error structs or fmt.Errorf — how to choose

Code review on ClaudeGate. The HTTP handler that receives execution jobs was returning a 500 when the job queue was full. A colleague caught it in the logs: "this isn't an internal error, it's saturation — it should be a 503."

He was right. The problem was a single innocent line: all errors from Enqueue treated the same way, without distinguishing their nature. That's the moment you have to ask the real question: how do you structure errors so the caller can respond correctly?

The concrete bug — 500 instead of 503

The original code in the HTTP handler looked like this:

// ❌ Before — any enqueue error returns 500
if err := h.queue.Enqueue(j.ID); err != nil {
    writeError(w, http.StatusInternalServerError, "failed to enqueue job")
    return
}

"Queue full" and "internal error" are not the same thing. A server with a full queue is working correctly — it's temporarily saturated. A client receiving 500 assumes the server is broken and probably won't retry. A client receiving 503 Service Unavailable knows it can retry in a few seconds.

To distinguish between the two cases, the error needs to be identifiable. That's where sentinel errors come in.

The fix — sentinel error + errors.Is

A sentinel error is an error value declared at the package level that the caller can compare directly. That's exactly what we need here:

// In queue.go
var ErrQueueFull = errors.New("queue full")

func (q *Queue) Enqueue(jobID string) error {
    select {
    case q.jobs <- jobID:
        return nil
    default:
        return fmt.Errorf("%w: job %s", ErrQueueFull, jobID)
    }
}
// In handler.go
if err := h.queue.Enqueue(j.ID); err != nil {
    if errors.Is(err, queue.ErrQueueFull) {
        writeError(w, http.StatusServiceUnavailable, "server busy, retry later")
    } else {
        writeError(w, http.StatusInternalServerError, "failed to enqueue job")
    }
    return
}

The %w in fmt.Errorf matters: it wraps the original error inside the enriched message ("queue full: job abc-123"), and errors.Is knows how to traverse that wrapping to find ErrQueueFull. Without %w, you'd lose the ability to compare.

The 3 Go error patterns

This ClaudeGate case illustrates why Go has several ways to handle errors. Here are the three patterns, when to use each, and why.

Pattern 1: fmt.Errorf — the general case

When the caller doesn't distinguish between failure causes — it just propagates the error or logs it and moves on — fmt.Errorf is more than enough:

// ✅ The caller doesn't act differently based on error type
func processItem(ctx context.Context, item Item) error {
    result, err := fetchData(ctx, item.ID)
    if err != nil {
        return fmt.Errorf("failed to fetch data for item %s: %w", item.ID, err)
    }

    if err := store.Save(ctx, result); err != nil {
        return fmt.Errorf("failed to save result for item %s: %w", item.ID, err)
    }

    return nil
}

The %w is still useful here: it lets upper layers unwrap the error if they need to, without having to anticipate that upfront.

Pattern 2: Sentinel error — the caller distinguishes multiple cases

When the caller needs to make different decisions based on the reason for failure, it needs a comparable value. That's the role of sentinel errors:

// Declared at package level
var (
    ErrQueueFull   = errors.New("queue full")
    ErrJobNotFound = errors.New("job not found")
    ErrJobCanceled = errors.New("job canceled")
)

// Returned with enriched context
func (q *Queue) GetJob(id string) (*Job, error) {
    job, ok := q.jobs[id]
    if !ok {
        return nil, fmt.Errorf("%w: %s", ErrJobNotFound, id)
    }
    return job, nil
}

// Caller that distinguishes cases
job, err := q.GetJob(jobID)
if err != nil {
    switch {
    case errors.Is(err, ErrJobNotFound):
        http.NotFound(w, r)
    case errors.Is(err, ErrJobCanceled):
        writeError(w, http.StatusGone, "job was canceled")
    default:
        writeError(w, http.StatusInternalServerError, "unexpected error")
    }
    return
}

The standard library is full of sentinels: io.EOF, sql.ErrNoRows, context.Canceled, context.DeadlineExceeded. These are well-known values that any caller can compare directly.

Pattern 3: Error struct — the error carries data

Sometimes the caller needs more than a boolean signal — it needs structured data contained in the error itself. An error struct implementing the error interface handles that:

// Struct definition
type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation error on field %s: %s", e.Field, e.Message)
}

// Returning it
func validatePayload(p Payload) error {
    if p.Name == "" {
        return &ValidationError{Field: "name", Message: "required"}
    }
    return nil
}

// Caller extracting the data
if err := validatePayload(p); err != nil {
    var ve *ValidationError
    if errors.As(err, &ve) {
        respondWithFieldError(w, ve.Field, ve.Message)
        return
    }
    writeError(w, http.StatusInternalServerError, "validation failed")
    return
}

It's errors.As and not errors.Is for structs: you extract the concrete type rather than comparing a value. The standard library does the same with *os.PathError (carries Op + Path + Err) or *url.Error (carries Method + URL + Err).

errors.Is vs errors.As — the difference

Both traverse the wrapping chain created by %w, but they do different things:

// errors.Is — compares values (for sentinel errors)
// Returns true if any error in the chain == ErrQueueFull
errors.Is(err, ErrQueueFull)

// errors.As — extracts a type (for error structs)
// Walks the chain, assigns the first *ValidationError found into &ve
var ve *ValidationError
errors.As(err, &ve)

The classic trap with error structs:

// ❌ Direct type assertion — doesn't traverse %w wrapping
if _, ok := err.(*ValidationError); ok {
    // Misses if the error was wrapped with fmt.Errorf("...: %w", ve)
}

// ✅ errors.As — traverses correctly
var ve *ValidationError
if errors.As(err, &ve) {
    // Works even if wrapped
}

The decision table

Situation Pattern
The caller propagates or logs, no case distinction needed fmt.Errorf("context: %w", err)
The caller distinguishes multiple failure causes var ErrXxx = errors.New(...) + errors.Is
The error must carry structured data Struct implementing error + errors.As

What not to do

Three anti-patterns that keep showing up in code reviews:

// ❌ Comparing error strings — fragile and unmaintainable
if err.Error() == "queue full" {
    // Breaks as soon as you change the error message
}

// ❌ Direct type assertion — misses wrapped errors
if _, ok := err.(*ValidationError); ok {
    // Doesn't work if the error was wrapped
}

// ❌ Sentinel error when data matters
var ErrNotFound = errors.New("not found")
// Which ID? Which resource type? The caller has no idea.
// Better: a struct that carries that information

The last point is subtle. sql.ErrNoRows works as a sentinel because the context (which query, which ID) is already known to the caller. But if the error itself needs to carry that context, a struct is more appropriate.

Standard library examples worth remembering

The Go standard library is a good guide to what works in practice over time:

  • io.EOF — sentinel, no additional data, the caller just knows reading is done
  • sql.ErrNoRows — sentinel, the caller distinguishes "no result" from "connection error"
  • context.Canceled / context.DeadlineExceeded — two distinct sentinels, the caller can react differently to each
  • *os.PathError — struct, carries Op + Path + Err: essential for displaying a useful error message
  • *url.Error — struct, carries Method + URL + Err for debugging HTTP calls

Conclusion

The rule is simple: pick the simplest pattern that meets the caller's actual needs. If the caller doesn't need to distinguish cases, fmt.Errorf is enough. If the caller needs to make different decisions, a sentinel error is the right tool. If the error needs to carry structured data, an error struct is the answer.

In the ClaudeGate case, the fix was small — a sentinel error and two lines in the handler. But the difference for API clients was significant: a 503 with a "retry later" message gives them actionable information. A generic 500 tells them nothing useful.

Go's error system is deliberately minimal. It's on us to choose the right granularity based on what the caller actually needs to know — not based on what looks cleaner to write.

Comments (0)