skills/go-error-hygiene/SKILL.md
Detect and fix Go error handling antipatterns across a codebase. Use when auditing error handling, fixing double-handled errors, removing log-and-return patterns, cleaning up log-and-wrap helpers, or when the user asks to analyze error handling hygiene, find error handling violations, or ensure errors are handled exactly once. Covers detection patterns, classification of true vs false positives, fix strategies for interior vs boundary code, and verification steps.
npx skillsauth add gonzaloserrano/gopilot go-error-hygieneInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Detect and fix the "handle errors more than once" antipattern across a Go codebase.
An error should be handled exactly once. Handling means one of:
If you do more than one at the same call site, you're double-handling. The most common violation: log AND return.
Search for functions that combine logging/tracing with error wrapping in a single call:
# Find definitions (handles both functions and methods)
rg -n "^func\b.*\b(Log|Trace|Record)\w*(Wrap|Error|Return)" --type go
rg -n "^func\b.*\b(Wrap|Return)\w*(Log|Trace|Record)" --type go
# Find usages of common helpers
rg -n "LogAndWrapError|logAndReturn|wrapAndLog|traceAndWrap" --type go
Read each definition. If the function both logs/traces AND returns a wrapped error, it's a codified antipattern.
Search for logging calls near error returns:
# Standard library log (exclude Fatal -- it's terminal, not double-handling)
rg -n "log\.(Printf|Println|Print)\b" --type go -A 3
# Zap
rg -n "zap\.L\(\)\.(Error|Warn)|logger\.(Error|Warn|Errorw|Warnw)" --type go -A 3
# Slog
rg -n "slog\.(Error|Warn)" --type go -A 3
# OpenTracing/OpenTelemetry span logging
rg -n "span\.(LogFields|SetTag|RecordError|AddEvent)|tracing\.LogError" --type go -A 3
For each match, check whether a return ...err follows within 1-3 lines. If yes, it's a double-handle candidate.
rg -n "(Error|Warn).*zap\.Error\(err\)" --type go -A 3
Look for return err (without wrapping) after a log call. This is double-handling AND loses context.
# Count helper usage per file
rg -c "LogAndWrapError|logAndReturn|wrapAndLog" --type go | sort -t: -k2 -rn
# Count total occurrences
rg "LogAndWrapError" --type go --count-matches
| Pattern | Example |
|---------|---------|
| Log + return wrapped | log.Error(...); return fmt.Errorf(...) |
| Log-and-wrap helper | return LogAndWrapError(span, msg, err) |
| Span log + return | tracing.LogError(span, ...); return fmt.Errorf(...) |
| Log + return bare err | log.Error(...); return err |
| Pattern | Why |
|---------|-----|
| Log + return nil / continue | Error is absorbed, not propagated. Logging IS the single handling. |
| Log in goroutine that can't return | No caller to propagate to. |
| Interface method that can't return error (e.g., Collect()) | Logging is the only option. |
| Boundary handler that logs + returns HTTP/gRPC status | This IS the top-level handler -- it's handling once at the boundary. |
| Log + panic / os.Exit / log.Fatal | Terminal -- not propagation. |
| Log in deferred cleanup (e.g., defer tx.Rollback) | Deferred functions can't return errors to the caller. |
| Metrics counter + return error | Metrics are aggregated counters, not per-event noise. Not double-handling. |
Interior code (repositories, services, domain logic, library packages):
Boundary code (HTTP handlers, gRPC interceptors, worker loops, main, background goroutines):
Interior: log-and-wrap helper → just wrap
// Before
return errorsutil.LogAndWrapError(span, "query failed", err)
// After
return fmt.Errorf("query failed: %w", err)
// Multi-return variant -- same fix, preserve other return values
// Before
return false, errorsutil.LogAndWrapError(span, "query failed", err)
// After
return false, fmt.Errorf("query failed: %w", err)
After each replacement, check whether span and the errorsutil import are still used elsewhere in the function/file. If span is now unused, check whether the span setup (span, ctx := opentracing.StartSpanFromContext(...) + defer span.Finish()) is still needed for tracing the operation itself. If the span only existed for LogAndWrapError calls, removing it is a separate refactor -- mark it as a follow-up, don't block the error hygiene fix on it.
Interior: log + return wrapped → just return wrapped
// Before
if err != nil {
zap.L().Error("connect failed", zap.Error(err))
return fmt.Errorf("connect: %w", err)
}
// After
if err != nil {
return fmt.Errorf("connect: %w", err)
}
Interior: log + return bare error → wrap and return
// Before
if err != nil {
zap.L().Error("query failed", zap.Error(err))
return err
}
// After
if err != nil {
return fmt.Errorf("query: %w", err)
}
Interior: span log + return → just return
// Before
if err != nil {
tracing.LogError(span, "fetch user", err)
return fmt.Errorf("fetch user: %w", err)
}
// After
if err != nil {
return fmt.Errorf("fetch user: %w", err)
}
Verify the boundary handler (HTTP handler, worker loop, gRPC interceptor) logs the error. If no boundary handler exists, add one:
// Boundary example: worker loop
func (w *Worker) Run(ctx context.Context) {
for job := range w.jobs {
if err := w.process(ctx, job); err != nil {
w.logger.Error("job failed",
zap.String("job_id", job.ID),
zap.Error(err),
)
// handle: retry, mark failed, etc.
}
}
}
If the codebase uses tracing, add span error recording at the boundary (middleware/interceptor), not at every interior call site:
// Boundary: tracing middleware
func tracingInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
span, ctx := opentracing.StartSpanFromContext(ctx, info.FullMethod)
defer span.Finish()
resp, err := handler(ctx, req)
if err != nil {
tracing.LogError(span, info.FullMethod, err)
}
return resp, err
}
After fixing all call sites for a helper like LogAndWrapError:
rg "LogAndWrapError" --type gogoimports to clean upWork file-by-file, highest call count first:
go build ./... -- catches unused vars/imports from the fixgo test ./<affected-package>/... -- test only what changed, not the whole repogolangci-lint run ./... onceAfter all files are done, clean up unused helpers and imports.
"We'll lose span/trace logging!" Move it to middleware/interceptors. One place records all errors with traces, not hundreds of scattered call sites.
"Some errors need extra fields in the log."
Use error wrapping with structured context: fmt.Errorf("user %s query %s: %w", userID, query, err). The boundary logger extracts what it needs from the error chain.
"What if the boundary doesn't log?" Then fix the boundary. The answer to "my boundary doesn't log" is not "log at every interior layer" -- it's "add proper boundary error handling."
development
Go programming language skill for writing idiomatic Go code, code review, error handling, testing, concurrency, security, and program design. Use when writing, reviewing, debugging, or asking about Go code — even if the user doesn't explicitly mention 'Go best practices'. Also use when: reviewing Go PRs, debugging Go tests, fixing Go errors, designing Go APIs, implementing security-sensitive code, handling user input, authentication, sessions, cryptography, building resource-oriented gRPC APIs with Google AIP standards, configuring golangci-lint, setting up structured logging with slog, or any question about Go idioms and patterns. Covers table-driven tests, error wrapping, goroutine patterns, interface design, generics, iterators, stdlib patterns up to Go 1.26, OWASP security practices, and Google AIP (API Improvement Proposals) with einride/aip-go for pagination, filtering, ordering, field masks, and resource names.
tools
TDD with baby steps for Go. Use when writing tests, doing TDD, practicing red-green-refactor, or when test cycles feel too large and risky. Also use when the user asks about incremental test development, test-first workflow, or wants help breaking a feature into small testable steps. Covers table-driven tests, testify, t.Run subtests, t.Helper, transformation priority premise, and incremental test progression.
tools
Use when work should span one or more detached tasks but still behave like one job with a single owner context. TaskFlow is the durable flow substrate under authoring layers like Lobster, ACPX, plugins, or plain code. Keep conditional logic in the caller; use TaskFlow for flow identity, child-task linkage, waiting state, revision-checked mutations, and user-facing emergence.
tools
# Lobster Lobster executes multi-step workflows with approval checkpoints. Use it when: - User wants a repeatable automation (triage, monitor, sync) - Actions need human approval before executing (send, post, delete) - Multiple tool calls should run as one deterministic operation ## When to use Lobster | User intent | Use Lobster? | | ------------------------------------------------------ | --------------------------