1. Wrapping Errors with %w
fmt.Errorf with the %w verb wraps an existing error inside
a new one — adding context ("what was happening when this failed") while preserving
the original error so it can still be inspected later.
func GetTask(db *sql.DB, id int) (*Task, error) {
// ... query, then:
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("task %d: %w", id, ErrNotFound)
}
// ...
}
func handleGetTask(w http.ResponseWriter, r *http.Request) {
task, err := GetTask(db, id)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "task not found")
return
}
writeError(w, http.StatusInternalServerError, "internal error")
}
}
Each layer adds its own context ("task %d: %w") without destroying the
original ErrNotFound underneath — a plain fmt.Errorf("task %d: %v", id, err)
(with %v instead of %w) would format the same message but
lose the ability to programmatically check what kind of error it originally was.
2. errors.Is & errors.As
var ErrNotFound = errors.New("not found")
if errors.Is(err, ErrNotFound) {
// true even if err is a wrapped chain several layers deep,
// as long as ErrNotFound is somewhere in that chain
}
type ValidationError struct {
Field string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("invalid field: %s", e.Field)
}
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Println("failed field:", valErr.Field) // unwrapped the concrete type back out
}
Use errors.Is when checking against a known sentinel value (like
ErrNotFound); use errors.As when you need the concrete
error type back to read fields off it. Both walk the entire wrapped chain
automatically — created by every %w along the way — so the check
works no matter how many layers of context were added since the original error.
3. Structured Logging with slog
log.Println("user", id, "not found") produces text a human can read
but a machine has to parse with regex. slog (standard library since Go
1.21) logs structured key-value data instead, in a format tools can actually query.
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("task created", "task_id", task.ID, "user_id", userID)
logger.Error("failed to save task", "error", err, "task_id", task.ID)
{"time":"2026-01-15T10:30:00Z","level":"INFO","msg":"task created","task_id":42,"user_id":7}
That JSON-lines format is directly queryable by any log aggregation tool — "show me
every error where task_id is 42" is a real, fast query against
structured logs, and effectively unanswerable against a pile of free-text log lines
at any real production scale.
4. Basic Metrics & Tracing Hooks
Logs answer "what happened." Metrics answer "how often, how fast, in aggregate" — and a tiny bit of instrumentation goes a long way.
var requestDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{Name: "http_request_duration_seconds"},
[]string{"path", "status"},
)
func metricsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: 200}
next.ServeHTTP(rec, r)
requestDuration.
WithLabelValues(r.URL.Path, strconv.Itoa(rec.status)).
Observe(time.Since(start).Seconds())
})
}
This is deliberately the same middleware pattern from Week 11 — metrics collection
is just another cross-cutting concern, composed the same way logging and recovery
were. A full tracing setup (correlating a request across multiple services) is a
deeper topic than this course covers, but the entry point is the same:
context (Week 7) carrying a trace ID through the same call chain that
already carries cancellation.
5. Hands-on Exercise
Instrument the task API for production
Replace ad hoc error and log handling in last week's API with this week's patterns.
Requirements:
- Wrap every error crossing a function boundary in your
Storeimplementation with%wand meaningful context, rather than returning it bare. - Replace every
log.Println/fmt.Printlnin the API with structuredslogcalls, including relevant fields (request path, task ID, error) on each. - Use
errors.Isin your HTTP handlers to distinguish a not-found error from a generic internal error, confirmed still working correctly through the wrapped chain from the store layer. - Add a metrics middleware (Prometheus client library or even a simple in-memory counter is fine) recording request count and duration per route.
- Deliberately trigger a wrapped not-found error and confirm, by reading the log output, that both the original sentinel and the added context are visible.
If errors.Is(err, ErrNotFound) returns false even though you're sure ErrNotFound is somewhere in the chain, check every wrap along the path used %w and not %v — a single %v anywhere in the chain breaks the link, since it formats the error into a plain string and discards the ability to unwrap past that point entirely.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
What is the practical difference between wrapping an error with %w versus formatting it with %v?
What is the practical difference between wrapping an error with %w versus formatting it with %v?
%w preserves a reference to the original error inside the new one, so errors.Is/errors.As can still find and inspect it later, potentially through several more layers of wrapping. %v only formats the error's message into a new string — the result looks identical when printed, but the original error value itself is gone, and any later errors.Is check against it will fail.
Q2
When should you reach for errors.As instead of errors.Is?
When should you reach for errors.As instead of errors.Is?
errors.Is answers a yes/no question against a known sentinel value ("is this a not-found error"). errors.As is for when you need the actual concrete error value back — to read a field off a custom error type like ValidationError.Field — not just confirm its presence.
Q3
Why is a structured log line (JSON with named fields) more useful in production than an equivalent free-text log message?
Why is a structured log line (JSON with named fields) more useful in production than an equivalent free-text log message?
A structured field like task_id is directly and reliably queryable by log aggregation tooling — "every error for this task_id" is a precise query. A free-text message requires the same information to be extracted with regex or string parsing, which is fragile against even small changes in message wording and much slower to query at real production log volumes.
Q4
Why does the metrics middleware in this week's example follow the exact same shape as Week 11's logging and recovery middleware?
Why does the metrics middleware in this week's example follow the exact same shape as Week 11's logging and recovery middleware?
Metrics collection is another cross-cutting concern that needs to run around every request regardless of which specific handler serves it — exactly the same requirement logging and panic recovery had. Reusing the middleware pattern means all three concerns compose cleanly in the same chain, rather than each needing its own bespoke wiring.