Go/
Lesson

Error handling is the single biggest source of bugs in AI-generated Go code. AI can write function signatures and business logic competently, but it consistently makes mistakes with error handling: swallowing errors, wrapping incorrectly, using %v instead of %w, returning generic messages that destroy debuggability. This lesson goes deep because this is where your ability to evaluate AI output matters most.

Why Go doesn't have exceptions

Most languages use try/catch. Go uses return values. This is not a limitation, it's a design choice that makes error handling visible and predictable:

// In Python, errors are invisible until they blow up:
// data = json.loads(text)  # might raise, might not
//
// In Go, every possible failure is in the signature:
func parseConfig(data []byte) (*Config, error) {
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("parsing config: %w", err)
    }
    return &cfg, nil
}

The error interface is one method:

type error interface {
    Error() string
}

Any type implementing Error() string satisfies this interface. The built-in nil value means "no error." That's the entire system.

02

The if err != nil pattern

You'll see this pattern hundreds of times in any Go codebase:

file, err := os.Open("config.json")
if err != nil {
    return fmt.Errorf("opening config: %w", err)
}
defer file.Close()

data, err := io.ReadAll(file)
if err != nil {
    return fmt.Errorf("reading config: %w", err)
}

Yes, it's verbose. That's the point. Every failure path is explicit and handled at the call site. There are no invisible exception paths, no surprise panics from three layers deep.

AI pitfall
AI frequently generates this anti-pattern, opening a file and deferring Close without checking the error first:
> file, _ := os.Open("data.txt")
> defer file.Close() // PANIC: file is nil
>

If os.Open fails, file is nil. Calling Close() on nil panics. Always check the error before the defer. This is one of the most common bugs in AI-generated Go.
03

Wrapping errors with context

Raw errors are useless for debugging. "file not found" tells you nothing. Which file? Why were you opening it? Error wrapping adds context at each layer:

func loadUserProfile(userID string) (*Profile, error) {
    path := filepath.Join("profiles", userID+".json")
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("loading profile for user %s: %w", userID, err)
    }

    var profile Profile
    if err := json.Unmarshal(data, &profile); err != nil {
        return nil, fmt.Errorf("parsing profile for user %s: %w", userID, err)
    }

    return &profile, nil
}

The %w verb in fmt.Errorf wraps the original error, preserving the chain for errors.Is() and errors.As(). The %v verb converts the error to a string, breaking the chain.

VerbEffectChain preserved?
%wWraps error, original accessible via UnwrapYes
%vConverts to string, original is lostNo
%sSame as %v for errorsNo
AI pitfall
This is the most common AI error-handling mistake. AI generates fmt.Errorf("failed: %v", err) instead of fmt.Errorf("failed: %w", err). The code compiles and runs. The error message even looks right. But errors.Is() and errors.As() stop working because the chain is broken. Every time you see %v with an error in AI output, change it to %w.
04

Sentinel errors

Sentinel errors are package-level variables that represent specific, checkable failure conditions:

var (
    ErrNotFound     = errors.New("not found")
    ErrUnauthorized = errors.New("unauthorized")
    ErrConflict     = errors.New("resource conflict")
)

func getUser(id int) (*User, error) {
    user, ok := users[id]
    if !ok {
        return nil, ErrNotFound
    }
    return user, nil
}

Callers check for specific errors to decide how to respond:

user, err := getUser(42)
if errors.Is(err, ErrNotFound) {
    // return 404
} else if err != nil {
    // return 500
}

The standard libraryWhat is standard library?A collection of ready-made tools that come built into a language - no install required. Covers common tasks like reading files or making web requests. uses sentinels extensively: io.EOF, sql.ErrNoRows, os.ErrNotExist.

AI pitfall
AI sometimes checks sentinel errors with == instead of errors.Is():
> if err == ErrNotFound { // WRONG, breaks if error was wrapped
> if errors.Is(err, ErrNotFound) { // RIGHT, traverses the chain
>

Direct comparison only works if the error was never wrapped. Since wrapping is best practice, always use errors.Is().
05

errors.Is() vs errors.As()

These two functions serve different purposes when inspecting error chains:

// errors.Is - "is this specific error anywhere in the chain?"
if errors.Is(err, os.ErrNotExist) {
    fmt.Println("file doesn't exist")
}

// errors.As - "extract a specific error type from the chain"
var pathErr *os.PathError
if errors.As(err, &pathErr) {
    fmt.Printf("operation %s failed on path %s\n", pathErr.Op, pathErr.Path)
}

errors.Is() answers yes/no. errors.As() extracts the typed error so you can access its fields. Both traverse the entire wrapped chain.

FunctionQuestion it answersUse when
errors.Is(err, target)Does this chain contain this exact error?Checking sentinels: ErrNotFound, io.EOF
errors.As(err, &target)Does this chain contain this error type?Extracting fields from custom error types
err == targetIs this the exact same error object?Almost never, use errors.Is() instead
06

Custom error types

When you need errors with structured data, implement the error interface on a struct:

type ValidationError struct {
    Field   string
    Message string
}

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

type DatabaseError struct {
    Operation string
    Table     string
    Cause     error
}

func (e *DatabaseError) Error() string {
    return fmt.Sprintf("db %s on %s: %v", e.Operation, e.Table, e.Cause)
}

// Implement Unwrap so errors.Is/As can traverse through it
func (e *DatabaseError) Unwrap() error {
    return e.Cause
}

The Unwrap() method is what makes errors.Is() and errors.As() work through your custom type. Without it, the chain stops at your error.

AI pitfall
AI generates custom error types but frequently forgets Unwrap(). The error looks correct, it has an Error() method, it stores a cause, but errors.Is(err, someSentinel) returns false because the chain can't be traversed. Always check that AI-generated custom errors implement Unwrap() when they wrap another error.
07

The seven deadly sins of AI error handling

Here's a decision table for reviewing AI-generated Go error handling. These are the patterns to flag:

SinWhat AI generatesThe problemFix
Swallowed errorresult, _ := riskyCall()Silent failure, nil dereferenceAlways check: if err != nil
Lost chainfmt.Errorf("fail: %v", err)errors.Is() breaksUse %w not %v
No contextreturn err"file not found", which file?return fmt.Errorf("loading user %d: %w", id, err)
Panic abusepanic(err)Crashes the whole programReturn the error, let callers decide
Log and returnlog.Println(err); return errError gets logged twice up the chainEither log OR return, not both
Generic messagereturn errors.New("something went wrong")Useless for debuggingInclude what operation failed and relevant IDs
Missing UnwrapCustom error without Unwrap()Chain traversal breaksAdd Unwrap() error method
08

When to panic vs return errors

panic exists in Go but is reserved for truly unrecoverable situations, programmer bugs, not runtimeWhat is runtime?The environment that runs your code after it's written. Some languages need a runtime installed on the machine; others (like Go) bake it into the binary. failures:

// Appropriate panic: programmer error, violated invariant
func MustCompileRegex(pattern string) *regexp.Regexp {
    re, err := regexp.Compile(pattern)
    if err != nil {
        panic(fmt.Sprintf("invalid regex %q: %v", pattern, err))
    }
    return re
}

// WRONG: panicking on a runtime condition
func getUser(id int) *User {
    user, err := db.FindUser(id)
    if err != nil {
        panic(err) // DON'T - return the error instead
    }
    return user
}

The Must prefix convention signals "this panics on error." You see it in template.Must(), regexp.MustCompile(). These are for initialization-time checks with hardcoded values, not runtime input.

AI pitfall
AI uses panic way too liberally, especially when porting code from languages with exceptions. If AI generates a panic(err) in a function that handles user input, network requests, or file I/O, that's a bug. Those are runtime errors that should be returned, not panicked.
09

Putting it together: reviewing AI error handling

When AI generates a Go function, run through this checklist:

  1. Does every fallible call have if err != nil?
  2. Is every error wrapped with context using %w?
  3. Are sentinel errors checked with errors.Is(), not ==?
  4. Do custom errors implement Unwrap() when wrapping?
  5. Is panic only used for programmer bugs, never runtimeWhat is runtime?The environment that runs your code after it's written. Some languages need a runtime installed on the machine; others (like Go) bake it into the binary. errors?
  6. Is each error either logged or returned, never both?
  7. Does the error message include enough context to debug without a stack traceWhat is stack trace?A list of function calls recorded at the moment an error occurs, showing exactly which functions were active and in what order.?

If the answer to any of these is no, ask AI to fix it. Error handling is where Go code quality lives or dies.