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.
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.
> 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.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.
| Verb | Effect | Chain preserved? |
|---|---|---|
%w | Wraps error, original accessible via Unwrap | Yes |
%v | Converts to string, original is lost | No |
%s | Same as %v for errors | No |
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.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.
== 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().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.
| Function | Question it answers | Use 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 == target | Is this the exact same error object? | Almost never, use errors.Is() instead |
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.
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.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:
| Sin | What AI generates | The problem | Fix |
|---|---|---|---|
| Swallowed error | result, _ := riskyCall() | Silent failure, nil dereference | Always check: if err != nil |
| Lost chain | fmt.Errorf("fail: %v", err) | errors.Is() breaks | Use %w not %v |
| No context | return err | "file not found", which file? | return fmt.Errorf("loading user %d: %w", id, err) |
| Panic abuse | panic(err) | Crashes the whole program | Return the error, let callers decide |
| Log and return | log.Println(err); return err | Error gets logged twice up the chain | Either log OR return, not both |
| Generic message | return errors.New("something went wrong") | Useless for debugging | Include what operation failed and relevant IDs |
| Missing Unwrap | Custom error without Unwrap() | Chain traversal breaks | Add Unwrap() error method |
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.
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.Putting it together: reviewing AI error handling
When AI generates a Go function, run through this checklist:
- Does every fallible call have
if err != nil? - Is every error wrapped with context using
%w? - Are sentinel errors checked with
errors.Is(), not==? - Do custom errors implement
Unwrap()when wrapping? - Is
paniconly 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? - Is each error either logged or returned, never both?
- 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.