Error Handling — Learning to Love if err != nil
In part 3 I covered goroutines and channels, and how Go's concurrency model sidesteps a lot of the ceremony I was used to from the JVM. This time I'm tackling the thing I complained about in part 1 of this series before I'd even really tried it: error handling. I called if err != nil repetitive back then. A few weeks and a lot of real code later, I owe Go a partial apology.
No Exceptions, On Purpose
Coming from Java, the absence of try/catch is the first thing that feels like a missing feature. It isn't — it's a deliberate design choice. In Go, errors are just values. A function that can fail returns an error as its last return value, and the caller decides what to do with it, right there, inline:
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
func main() {
result, err := divide(10, 0)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("result:", result)
}
That's the pattern you'll write hundreds of times in Go: call a function, check err, handle it or bail out, move on. No hidden control flow jumping up the call stack to whichever catch block happens to match. No checked-exception signatures cluttering method declarations. No RuntimeException quietly skipping past five layers of code that had no idea it could happen. Whatever can fail is sitting right there in the function signature, and you're forced to look at it.
Why the Repetition Is the Point
My part 1 complaint was that if err != nil everywhere feels manual. It is manual — and that's exactly the trade Go is making. In Java, an exception thrown deep in a call stack can silently propagate through layers of code that never declared they might fail, and you only find out where things actually break by reading a stack trace after the fact. In Go, every single point where something can go wrong is visible in the source, in order, as you read top to bottom:
func processOrder(id string) error {
order, err := fetchOrder(id)
if err != nil {
return fmt.Errorf("fetching order: %w", err)
}
if err := validateOrder(order); err != nil {
return fmt.Errorf("validating order: %w", err)
}
if err := chargePayment(order); err != nil {
return fmt.Errorf("charging payment: %w", err)
}
return nil
}
Three things can fail here, and you can see all three without jumping anywhere. That %w verb in fmt.Errorf is doing something specific — it's wrapping the original error rather than discarding it, which means the caller can still inspect what actually went wrong underneath your added context. It's the closest thing Go has to a stack trace, except you control exactly how much context gets added at each layer instead of getting a wall of frames you have to parse yourself.
errors.Is and errors.As: Inspecting Wrapped Errors
Once you're wrapping errors as they bubble up, you need a way to check what's actually inside the wrapping. That's what errors.Is and errors.As are for:
var ErrNotFound = errors.New("order not found")
func fetchOrder(id string) (Order, error) {
if id == "" {
return Order{}, ErrNotFound
}
return Order{ID: id}, nil
}
func main() {
err := processOrder("")
if errors.Is(err, ErrNotFound) {
fmt.Println("order doesn't exist, returning 404")
return
}
fmt.Println("unexpected error:", err)
}
Even though processOrder wrapped the original error with fmt.Errorf("fetching order: %w", err), errors.Is can still see through the wrapping and match it against the sentinel ErrNotFound. This is the Go equivalent of catch (OrderNotFoundException e) — except instead of a type hierarchy of custom exception classes, you define a handful of sentinel error values (or custom error types, when you need to carry extra data) and compare against them explicitly wherever the distinction matters.
errors.As does the same thing but for pulling out a specific error type rather than matching a specific value — useful when you've defined a custom struct that implements the error interface and carries extra fields:
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Msg)
}
func main() {
err := processOrder("123")
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Println("invalid field:", valErr.Field)
}
}
Any type with an Error() string method satisfies the error interface — same implicit-satisfaction rule from part 2, just applied to errors specifically.
panic and recover: Not Your Exception Mechanism
Go does have panic and recover, and the instinct coming from Java is to treat them like throw and catch. That's a mistake that took me a minute to unlearn. panic is reserved for genuinely unrecoverable situations — a programming bug, a nil pointer you should never have dereferenced, an invariant that's been violated so badly that continuing execution would be worse than crashing:
func mustParseConfig(raw string) Config {
cfg, err := parseConfig(raw)
if err != nil {
panic(fmt.Sprintf("invalid config, cannot start: %v", err))
}
return cfg
}
Notice the must prefix — that's a real Go convention signaling "this panics instead of returning an error, use it only when failure should be impossible or fatal." You'll see it on things like regexp.MustCompile, used for regex patterns that are hardcoded at compile time and should never actually fail to parse. If a function can fail in a way the caller might reasonably want to handle, it returns an error. panic is for "this program cannot continue," not "this operation didn't work."
Where I've Landed
A few weeks in, the repetition stopped bothering me, for a specific reason: I always know exactly where my code can fail, just by reading it. No exception is escaping from three function calls deep and surfacing somewhere I didn't expect. The trade-off is real — Go code has more visible lines dedicated to error checking than the equivalent Java would — but every one of those lines is telling me something true about the function I'm calling, instead of trusting that a try/catch block somewhere upstream will catch what I forgot to handle.
What still isn't fully natural yet: remembering to wrap errors with enough context every single time (it's easy to return a bare err and lose the "where did this happen" information), and resisting the urge to define a sprawling hierarchy of custom error types out of old OOP habits when a couple of sentinel errors would do.
Up Next
With types, concurrency, and error handling covered, part 5 is where this series gets concrete: building a real REST API with Gin, including the error-handling and concurrency patterns from the last two posts actually wired into request handlers.
If you've made the jump from exceptions to Go's explicit error returns, did the repetition bother you at first the way it bothered me — and did it fade the same way?
Top comments (0)