Go's error handling model is one of the first things developers coming from Python or Java genuinely dislike. It's also one of the most persistent sources of bad patterns in first-pass Go code. We see error handling issues on Go PRs more consistently than any other concept category, and the patterns are recognizable enough that you can usually tell which language someone came from by how they handle errors.
This piece covers the specific patterns that show up wrong in PRs, why they form the way they do, and what they tell you about the underlying mental model that needs adjusting.
The wrapping problem
The most common error handling issue we see is incorrect error wrapping. Go 1.13 added fmt.Errorf with the %w verb to allow wrapping errors in a way that preserves the original error for errors.Is() and errors.As() inspection. The %v verb just formats the error string, losing the wrappable reference.
In practice, developers who haven't internalized this distinction write:
return fmt.Errorf("database lookup failed: %v", err)
when they should write:
return fmt.Errorf("database lookup failed: %w", err)
The difference is invisible until someone higher in the call stack tries to check whether the error is a specific type, at which point errors.Is(err, sql.ErrNoRows) returns false when it should return true. This produces bugs that are difficult to trace because the error message looks correct, but the error identity chain is broken.
Why does this happen? Developers who learned Go before 1.13, or who learned from older tutorials, never saw %w. Developers from Python who are accustomed to raise preserving stack traces automatically don't realize they need to explicitly preserve error identity in Go. The behavior difference is subtle enough that it passes casual inspection.
Sentinel errors and comparison
Sentinel errors are package-level variables that represent known error states, like io.EOF or sql.ErrNoRows. The correct way to check for them is errors.Is(), not == comparison, because a wrapped error won't equal the sentinel directly.
Python developers are used to checking exception types with isinstance() or catching by exception class. Java developers are used to catching by exception type in a try-catch block. Both approaches check the type of the outermost error. Go's errors.Is() checks the full unwrapped chain. When developers write err == sql.ErrNoRows instead of errors.Is(err, sql.ErrNoRows), they've brought the "check the outer thing" pattern into a language where errors get wrapped.
The code looks fine. It passes tests if the tests aren't wrapping errors in the test fixtures. It fails silently in production when the database layer wraps errors for additional context.
Error handling locality
This one is less a syntax problem and more a structural pattern mismatch. Go's convention is to handle errors close to where they occur, return them up the call stack with added context, and let the caller decide what to do. Python and Java both support centralized exception handling, where you catch at a high level and handle there.
A Python developer's first Go function often looks like this: call several things, check errors at the end, or wrap everything in a deferred recover. That's the Python pattern of "try everything, handle exceptions once." In Go, the convention is:
result, err := doThing()
if err != nil {
return nil, fmt.Errorf("doing thing: %w", err)
}
repeated at each call site. This is verbose by design. The verbosity forces each site to decide whether the error is recoverable, adds context to what was happening when the error occurred, and makes the error propagation path visible in the code.
When we see a PR where a developer is handling errors in a deferred panic recovery or collecting errors in a slice and checking them all at the end, we know they're still operating from the exception-handling mental model. The fix isn't just syntactic. The mental model needs to change: errors are values, they flow up through explicit returns, and centralized handling is generally not the Go convention.
Ignoring errors silently
This is brief but worth naming. _, err := doThing() followed by no error check is one of the easier patterns to find in a review, but it's genuinely common in first-pass Go from developers who are used to languages where ignoring an error doesn't require explicit syntax.
The linter errcheck catches this and should be in any Go CI pipeline. But developers writing code who know the linter isn't running yet, or who are prototyping and don't clean up, leave these in. In PRs we grade, they show up regularly in early-stage code where the developer is trying to make things work before making them correct.
What the patterns tell you about learning state
The reason we track these error handling patterns at the concept level rather than just flagging syntax is that they cluster in predictable ways based on prior language experience.
A developer coming from Python shows the centralized handling pattern more than the wrapping pattern. A developer coming from Java shows the sentinel comparison pattern more often because Java exception hierarchies create an instinct to check types. A developer who learned Go from older tutorials shows %v instead of %w regardless of where they came from.
Knowing which pattern a developer is stuck on tells you which concept lesson to queue. Error wrapping requires understanding Go's error interface and what errors.Is() does. Handling locality requires understanding that Go doesn't have exceptions. Sentinel comparison requires understanding how errors.Is() traverses wrapped chains.
These are three different lessons. Getting the right one in front of the developer at the right point in their PRs is what closes the gap faster than hoping code review comments will be self-explanatory.
Go error handling is not uniquely difficult
One thing worth saying plainly: Go's error handling model is not objectively harder than other languages' approaches. It's different in ways that conflict with specific patterns from Python and Java. The difficulty isn't the language itself. It's the unlearning required when a strong prior pattern maps closely but not exactly to the Go approach.
A developer with no strong prior language in the exception-handling tradition sometimes learns Go error handling more quickly than a developer with seven years of Java, because they don't have the exception-catching reflex to override. What's hard about Go error handling is specific to what you're coming from. That's why the patterns are clustered and predictable, and why targeted feedback on the specific pattern you're exhibiting is more useful than a general explanation of how Go errors work.