references/ERROR-FLOW.md
# Error Flow Patterns
Detailed patterns for error flow, the handle-once principle, and logging
decisions.
## Indent Error Flow
Handle errors before proceeding with normal code. This improves readability by
enabling the reader to find the normal path quickly.
```go
// Good: Error handling first, normal code unindented
if err != nil {
// error handling
return // or continue, etc.
}
// normal code
```
```go
// Bad: Normal code hidden in else clause
if err != nil {
// error handling
} else {
// normal code that looks abnormal due to indentation
}
```
### Avoid If-with-Initializer for Long-Lived Variables
If you use a variable for more than a few lines, move the declaration out:
```go
// Good: Declaration separate from error check
x, err := f()
if err != nil {
return err
}
// lots of code that uses x
// across multiple lines
```
```go
// Bad: Variable scoped to else block, hard to read
if x, err := f(); err != nil {
return err
} else {
// lots of code that uses x
// across multiple lines
}
```
---
## Handle Errors Once
When a caller receives an error, it should handle each error **only once**.
Choose ONE response:
1. **Return the error** (wrapped or verbatim) for the caller to handle
2. **Log and degrade gracefully** (don't return the error)
3. **Match and handle** specific error cases, return others
**If you return an error, don't log it yourself** — let the caller handle it.
Logging and returning the same error is the most common "handle errors once"
violation, causing duplicate noise as callers up the stack also handle the error.
```go
// Bad: Logs AND returns - causes noise in logs
u, err := getUser(id)
if err != nil {
log.Printf("Could not get user %q: %v", id, err)
return err // Callers will also log this!
}
// Good: Wrap and return - let caller decide how to handle
u, err := getUser(id)
if err != nil {
return fmt.Errorf("get user %q: %w", id, err)
}
// Good: Log and degrade gracefully (don't return error)
if err := emitMetrics(); err != nil {
// Failure to write metrics should not break the application
log.Printf("Could not emit metrics: %v", err)
}
// Continue execution...
// Good: Match specific errors, return others
tz, err := getUserTimeZone(id)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
// User doesn't exist. Use UTC.
tz = time.UTC
} else {
return fmt.Errorf("get user %q: %w", id, err)
}
}
```
---
## Logging vs Returning Errors
> Handle an error exactly once — either log it or return it, never both.
### Decision Flow
```
Error encountered?
├─ Can the caller act on it? → Return the error (with context via %w)
├─ Is this the top of the call chain? → Log and handle (return HTTP status, exit, etc.)
└─ Neither? → Log at appropriate level and continue
```
### Don't Log and Return
```go
// Bad: error is logged AND returned — appears twice in logs
func process(ctx context.Context, id string) error {
result, err := fetch(ctx, id)
if err != nil {
log.Printf("failed to fetch %s: %v", id, err)
return fmt.Errorf("fetching %s: %w", id, err)
}
return handle(result)
}
// Good: return with context — let the caller decide whether to log
func process(ctx context.Context, id string) error {
result, err := fetch(ctx, id)
if err != nil {
return fmt.Errorf("fetching %s: %w", id, err)
}
return handle(result)
}
```
### Structured Logging
Prefer structured logging (`slog` in Go 1.21+, or `log/slog`-compatible
libraries) over `log.Printf` for production code:
```go
// Good: structured fields are machine-parseable
slog.Error("fetch failed", "id", id, "err", err)
// Avoid: unstructured string interpolation
log.Printf("fetch failed for %s: %v", id, err)
```
### Verbosity Levels
| Level | Use for |
|-------|---------|
| Error | Actionable failures that need attention |
| Warn | Degraded behavior that doesn't require immediate action |
| Info | Key lifecycle events (startup, shutdown, config loaded) |
| Debug | Diagnostic detail useful during development |
references/ERROR-TYPES.md
# Error Types Reference
This reference covers structured error types, sentinel errors, and how to choose
the right error type for your use case.
---
## Error Structure
> The error-type decision table is in the parent skill (SKILL.md § Error Types).
> This reference covers: expanded code examples, sentinel errors, error checking
> with `errors.Is`/`errors.As`, and structured error types.
**Key considerations**:
- Does the caller need to match the error with `errors.Is` or `errors.As`?
- Is the error message static or does it require runtime values?
- Exported error variables/types become part of your public API
```go
// No matching needed, static message
func Open() error {
return errors.New("could not open")
}
// Matching needed, static message - export a sentinel
var ErrCouldNotOpen = errors.New("could not open")
func Open() error {
return ErrCouldNotOpen
}
// Matching needed, dynamic message - use custom type
type NotFoundError struct {
File string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("file %q not found", e.File)
}
func Open(file string) error {
return &NotFoundError{File: file}
}
```
---
## Sentinel Errors
The simplest structured errors are unparameterized global values:
```go
// Good: Sentinel errors for programmatic checking
var (
// ErrDuplicate occurs if this animal has already been seen.
ErrDuplicate = errors.New("duplicate")
// ErrMarsupial occurs because we're allergic to marsupials.
ErrMarsupial = errors.New("marsupials are not supported")
)
func process(animal Animal) error {
switch {
case seen[animal]:
return ErrDuplicate
case marsupial(animal):
return ErrMarsupial
}
seen[animal] = true
return nil
}
```
---
## Checking Errors
For direct comparison (when errors are not wrapped):
```go
// Good: Direct comparison with sentinel
switch err := process(an); err {
case ErrDuplicate:
return fmt.Errorf("feed %q: %v", an, err)
case ErrMarsupial:
alternate := an.BackupAnimal()
return handlePet(alternate)
}
```
When errors may be wrapped, use `errors.Is`:
```go
// Good: Works with wrapped errors
switch err := process(an); {
case errors.Is(err, ErrDuplicate):
return fmt.Errorf("feed %q: %v", an, err)
case errors.Is(err, ErrMarsupial):
// Try to recover...
}
```
**Never** match errors based on string content:
```go
// Bad: Fragile string matching
if regexp.MatchString(`duplicate`, err.Error()) {...}
if regexp.MatchString(`marsupial`, err.Error()) {...}
```
---
## Structured Error Types
For errors needing additional programmatic information, use struct types:
```go
// Good: Structured error with accessible fields
type PathError struct {
Op string
Path string
Err error
}
func (e *PathError) Error() string {
return e.Op + " " + e.Path + ": " + e.Err.Error()
}
func (e *PathError) Unwrap() error { return e.Err }
```
Callers can use `errors.As` to extract the structured error:
```go
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Println("Failed path:", pathErr.Path)
}
```
---
## Quick Reference
| Scenario | Error Type |
|----------|------------|
| No matching needed, static message | `errors.New("message")` |
| No matching needed, dynamic message | `fmt.Errorf("msg: %v", val)` |
| Matching needed, static message | `var ErrFoo = errors.New(...)` |
| Matching needed, dynamic message | custom struct type |
| Checking sentinel errors | `errors.Is(err, ErrFoo)` |
| Extracting structured errors | `errors.As(err, &target)` |
references/WRAPPING.md
# Error Wrapping Reference
This reference covers error wrapping with `%v` vs `%w`, placement conventions,
adding context to errors, and logging best practices.
---
## Wrapping Errors: %v vs %w
> **Advisory**: Recommended best practice.
The choice between `%v` and `%w` significantly impacts how errors are propagated
and inspected.
### Use %v for Simple Annotation
Use `%v` when you want to:
- Add context without preserving the error chain for programmatic inspection
- Create fresh, independent errors (especially at system boundaries like
RPC/IPC)
- Log or display errors to humans
```go
// Good: %v at system boundary - hide internal details
func (s *Server) SuggestFortune(ctx context.Context, req *pb.Request) (*pb.Response, error) {
if err != nil {
return nil, fmt.Errorf("couldn't find fortune database: %v", err)
}
}
```
### Use %w for Error Chain Preservation
Use `%w` when you want callers to programmatically inspect the underlying error:
```go
// Good: %w preserves error chain for errors.Is/errors.As
func (s *Server) internalFunction(ctx context.Context) error {
if err != nil {
return fmt.Errorf("couldn't find remote file: %w", err)
}
}
// Caller can now check:
if errors.Is(err, fs.ErrNotExist) {
// Handle not found case
}
```
### When to Use Each
**Use %w when**:
- Adding context while preserving the original error for programmatic inspection
- You explicitly document and test the underlying errors you expose
**Use %v when**:
- At system boundaries (RPC, IPC, storage) to translate to canonical error space
- Logging or displaying to humans
- Creating independent errors that hide implementation details
---
## Placement of %w
> **Advisory**: Recommended best practice.
Place `%w` at the **end** of the error string so error text mirrors error chain
structure:
```go
// Good: %w at end - prints newest to oldest
err1 := fmt.Errorf("err1")
err2 := fmt.Errorf("err2: %w", err1)
err3 := fmt.Errorf("err3: %w", err2)
fmt.Println(err3) // err3: err2: err1
```
```go
// Bad: %w at start - prints oldest to newest (confusing)
err1 := fmt.Errorf("err1")
err2 := fmt.Errorf("%w: err2", err1)
err3 := fmt.Errorf("%w: err3", err2)
fmt.Println(err3) // err1: err2: err3
```
```go
// Bad: %w in middle - incoherent order
err1 := fmt.Errorf("err1")
err2 := fmt.Errorf("err2-1 %w err2-2", err1)
err3 := fmt.Errorf("err3-1 %w err3-2", err2)
fmt.Println(err3) // err3-1 err2-1 err1 err2-2 err3-2
```
**Pattern**: Use the form `context message: %w`
---
## Adding Information to Errors
> **Advisory**: Recommended best practice.
### Add Context, Not Redundancy
Add information that you have but the caller/callee might not. Avoid duplicating
information the underlying error already provides:
```go
// Good: Adds meaningful context
if err := os.Open("settings.txt"); err != nil {
return fmt.Errorf("launch codes unavailable: %v", err)
}
// Output: launch codes unavailable: open settings.txt: no such file or directory
```
```go
// Bad: Duplicates the filename
if err := os.Open("settings.txt"); err != nil {
return fmt.Errorf("could not open settings.txt: %v", err)
}
// Output: could not open settings.txt: open settings.txt: no such file or directory
```
### Don't Annotate Without Purpose
If the annotation only indicates failure without adding information, just return
the error:
```go
// Bad: Annotation adds nothing
return fmt.Errorf("failed: %v", err)
// Good: Just return the error
return err
```
---
## Logging Errors
> **Advisory**: Recommended best practice.
When you do log errors, use `log/slog` (Go 1.21+) with structured key-value
pairs and the appropriate level:
- **`slog.Error`**: Reserve for actionable issues that need investigation.
- **`slog.Warn`**: For issues that may need attention but aren't immediately
actionable.
- **`slog.Debug`**: For development tracing — only emitted when the handler's
level is set to `LevelDebug`.
```go
// Good: Structured logging with appropriate levels
for _, q := range queries {
slog.Debug("handling query", "query", q)
q.Run()
}
// Good: Guard expensive formatting behind a level check
if slog.Default().Enabled(context.Background(), slog.LevelDebug) {
slog.Debug("query plan", "explain", q.Explain())
}
// Bad: Expensive call evaluated even when debug logging is disabled
slog.Debug("query plan", "explain", q.Explain())
```
### Protect Sensitive Information
Be careful with PII (Personally Identifiable Information) in log messages. Many
log sinks are not appropriate for sensitive user data.
---
## Quick Reference
| Pattern | Guidance |
|---------|----------|
| `%v` | Use at system boundaries, for logging, to hide details |
| `%w` | Use to preserve error chain for programmatic inspection |
| `%w` placement | Always at the end: `"context: %w"` |
| Adding context | Add new info, don't duplicate existing info |
| Empty annotation | Just return `err` instead of `fmt.Errorf("failed: %v", err)` |
| Logging | Don't log and return; use appropriate log levels |
scripts/check-errors-ast.go
package main
import (
"encoding/json"
"fmt"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
const version = "1.1.0"
type finding struct {
File string `json:"file"`
Line int `json:"line"`
Rule string `json:"rule"`
Message string `json:"message"`
}
type options struct {
jsonOutput bool
checkBareReturn bool
limit int
target string
help bool
version bool
}
func usage() {
fmt.Fprintf(os.Stdout, `check-errors.sh v%s - Check Go code for common error handling anti-patterns
USAGE
bash check-errors.sh [options] [path]
OPTIONS
-h, --help Show this help message
-v, --version Show version
--json Output results as JSON
--no-bare-return Skip the bare 'return err' check (high false-positive rate)
--limit N Show at most N results (default: all)
`, version)
}
func main() {
opts, err := parseArgs(os.Args[1:])
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(2)
}
if opts.help {
usage()
return
}
if opts.version {
fmt.Printf("check-errors.sh v%s\n", version)
return
}
files, err := findGoFiles(opts.target)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(2)
}
if len(files) == 0 {
if opts.jsonOutput {
fmt.Println(`{"findings":[],"total":0,"truncated":false,"status":"no_go_files"}`)
} else {
fmt.Printf("No Go files found in: %s\n", opts.target)
}
return
}
findings := []finding{}
for _, file := range files {
fileFindings, err := analyzeFile(file, opts.checkBareReturn)
if err != nil {
fmt.Fprintf(os.Stderr, "error: parse %s: %v\n", file, err)
os.Exit(2)
}
findings = append(findings, fileFindings...)
}
sort.SliceStable(findings, func(i, j int) bool {
if findings[i].File == findings[j].File {
if findings[i].Line == findings[j].Line {
return findings[i].Rule < findings[j].Rule
}
return findings[i].Line < findings[j].Line
}
return findings[i].File < findings[j].File
})
total := len(findings)
truncated := false
if opts.limit > 0 && total > opts.limit {
findings = findings[:opts.limit]
truncated = true
}
if opts.jsonOutput {
out := struct {
Findings []finding `json:"findings"`
Total int `json:"total"`
Truncated bool `json:"truncated"`
}{Findings: findings, Total: total, Truncated: truncated}
data, err := json.Marshal(out)
if err != nil {
fmt.Fprintf(os.Stderr, "error: marshal JSON: %v\n", err)
os.Exit(2)
}
fmt.Println(string(data))
} else {
if total == 0 {
fmt.Println("No error handling anti-patterns found.")
return
}
fmt.Println("Error handling anti-patterns found:")
fmt.Println()
for _, item := range findings {
fmt.Printf(" %s:%d [%s] %s\n", item.File, item.Line, item.Rule, item.Message)
}
if truncated {
fmt.Printf(" ... and %d more (use --limit to adjust)\n", total-opts.limit)
}
fmt.Println()
fmt.Printf("Total: %d finding(s)\n", total)
}
if total > 0 {
os.Exit(1)
}
}
func analyzeFile(path string, checkBareReturn bool) ([]finding, error) {
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return nil, err
}
var findings []finding
var logLines []int
var errReturnLines []int
ast.Inspect(file, func(n ast.Node) bool {
switch node := n.(type) {
case *ast.BinaryExpr:
if node.Op != token.EQL && node.Op != token.NEQ {
return true
}
line := fset.Position(node.Pos()).Line
switch {
case isErrorCall(node.X) && isStringLiteral(node.Y):
findings = append(findings, finding{
File: path,
Line: line,
Rule: "string-error-compare",
Message: "comparing err.Error() to string; use errors.Is() or errors.As() instead",
})
case isStringLiteral(node.X) && isErrorCall(node.Y):
findings = append(findings, finding{
File: path,
Line: line,
Rule: "string-error-compare",
Message: "comparing string to err.Error(); use errors.Is() or errors.As() instead",
})
}
case *ast.CallExpr:
line := fset.Position(node.Pos()).Line
if isStringsContainsErrorCall(node) {
findings = append(findings, finding{
File: path,
Line: line,
Rule: "string-error-compare",
Message: "using strings.Contains on err.Error(); use errors.Is() or errors.As() instead",
})
}
if isLogCallWithErr(node) {
logLines = append(logLines, line)
}
case *ast.ReturnStmt:
if returnsErr(node) {
line := fset.Position(node.Return).Line
errReturnLines = append(errReturnLines, line)
if checkBareReturn {
findings = append(findings, finding{
File: path,
Line: line,
Rule: "bare-return-err",
Message: "returning err without wrapping context; consider fmt.Errorf('...: %w', err)",
})
}
}
}
return true
})
for _, retLine := range errReturnLines {
for i := len(logLines) - 1; i >= 0; i-- {
logLine := logLines[i]
if logLine >= retLine {
continue
}
if retLine-logLine > 5 {
break
}
findings = append(findings, finding{
File: path,
Line: logLine,
Rule: "log-and-return",
Message: fmt.Sprintf("error is both logged (line %d) and returned (line %d); handle errors once", logLine, retLine),
})
break
}
}
return findings, nil
}
func isErrorCall(expr ast.Expr) bool {
call, ok := expr.(*ast.CallExpr)
if !ok || len(call.Args) != 0 {
return false
}
sel, ok := call.Fun.(*ast.SelectorExpr)
return ok && sel.Sel.Name == "Error"
}
func isStringLiteral(expr ast.Expr) bool {
lit, ok := expr.(*ast.BasicLit)
return ok && lit.Kind == token.STRING
}
func isStringsContainsErrorCall(call *ast.CallExpr) bool {
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "Contains" {
return false
}
pkg, ok := sel.X.(*ast.Ident)
if !ok || pkg.Name != "strings" || len(call.Args) == 0 {
return false
}
return containsErrorCall(call.Args[0])
}
func containsErrorCall(expr ast.Expr) bool {
found := false
ast.Inspect(expr, func(n ast.Node) bool {
if found {
return false
}
if e, ok := n.(ast.Expr); ok && isErrorCall(e) {
found = true
return false
}
return true
})
return found
}
func isLogCallWithErr(call *ast.CallExpr) bool {
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return false
}
x, ok := sel.X.(*ast.Ident)
if !ok {
return false
}
switch x.Name {
case "log", "logger", "slog":
default:
return false
}
for _, arg := range call.Args {
if containsIdent(arg, "err") {
return true
}
}
return false
}
func containsIdent(expr ast.Expr, name string) bool {
found := false
ast.Inspect(expr, func(n ast.Node) bool {
if found {
return false
}
ident, ok := n.(*ast.Ident)
if ok && ident.Name == name {
found = true
return false
}
return true
})
return found
}
func returnsErr(stmt *ast.ReturnStmt) bool {
if len(stmt.Results) == 0 {
return false
}
ident, ok := stmt.Results[len(stmt.Results)-1].(*ast.Ident)
return ok && ident.Name == "err"
}
func findGoFiles(target string) ([]string, error) {
info, err := os.Stat(target)
if err == nil {
if !info.IsDir() {
if strings.HasSuffix(target, ".go") && !strings.HasSuffix(target, "_test.go") {
return []string{target}, nil
}
return nil, nil
}
return walkGoFiles(target)
}
if strings.HasSuffix(target, "/...") {
dir := strings.TrimSuffix(target, "/...")
if dir == "" {
dir = "."
}
if info, statErr := os.Stat(dir); statErr == nil && info.IsDir() {
return walkGoFiles(dir)
}
}
return nil, fmt.Errorf("path not found: %s", target)
}
func walkGoFiles(root string) ([]string, error) {
var files []string
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
switch d.Name() {
case ".git", "vendor":
return filepath.SkipDir
}
return nil
}
if strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") {
files = append(files, path)
}
return nil
})
sort.Strings(files)
return files, err
}
func parseArgs(args []string) (options, error) {
opts := options{target: ".", checkBareReturn: true}
var positionals []string
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "-h" || arg == "--help":
opts.help = true
case arg == "-v" || arg == "--version":
opts.version = true
case arg == "--json":
opts.jsonOutput = true
case arg == "--no-bare-return":
opts.checkBareReturn = false
case arg == "--limit":
if i+1 >= len(args) {
return opts, fmt.Errorf("--limit requires a number")
}
i++
limit, err := parseNonNegativeInt(args[i])
if err != nil {
return opts, fmt.Errorf("--limit must be a non-negative integer, got: %s", args[i])
}
opts.limit = limit
case strings.HasPrefix(arg, "--limit="):
value := strings.TrimPrefix(arg, "--limit=")
limit, err := parseNonNegativeInt(value)
if err != nil {
return opts, fmt.Errorf("--limit must be a non-negative integer, got: %s", value)
}
opts.limit = limit
case strings.HasPrefix(arg, "-"):
return opts, fmt.Errorf("unknown option: %s", arg)
default:
positionals = append(positionals, arg)
}
}
if len(positionals) > 1 {
return opts, fmt.Errorf("expected at most one path")
}
if len(positionals) == 1 {
opts.target = positionals[0]
}
return opts, nil
}
func parseNonNegativeInt(s string) (int, error) {
if s == "" {
return 0, fmt.Errorf("empty")
}
n := 0
for _, r := range s {
if r < '0' || r > '9' {
return 0, fmt.Errorf("invalid")
}
n = n*10 + int(r-'0')
}
return n, nil
}
scripts/check-errors.sh
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VERSION="1.1.0"
for arg in "$@"; do
case "$arg" in
-h|--help)
cat <<EOF
check-errors.sh v$VERSION - Check Go code for common error handling anti-patterns
USAGE
bash check-errors.sh [options] [path]
OPTIONS
-h, --help Show this help message
-v, --version Show version
--json Output results as JSON
--no-bare-return Skip the bare 'return err' check (high false-positive rate)
--limit N Show at most N results (default: all)
EOF
exit 0
;;
-v|--version)
echo "check-errors.sh v$VERSION"
exit 0
;;
esac
done
if ! command -v go >/dev/null 2>&1; then
echo "error: go is not installed or not in PATH" >&2
exit 2
fi
CACHE_ROOT="${XDG_CACHE_HOME:-${HOME:-${TMPDIR:-/tmp}}/.cache}/golang-skills"
if ! mkdir -p "$CACHE_ROOT"; then
CACHE_ROOT="${TMPDIR:-/tmp}/golang-skills-cache"
mkdir -p "$CACHE_ROOT"
fi
SRC="$SCRIPT_DIR/check-errors-ast.go"
STAMP="$(cksum "$SRC" | awk '{print $1 "-" $2}')"
BIN="$CACHE_ROOT/check-errors-ast-$STAMP"
if [[ ! -x "$BIN" ]]; then
GOCACHE="${GOCACHE:-$CACHE_ROOT/go-build}" go build -o "$BIN" "$SRC"
fi
exec "$BIN" "$@"