Documentation Index
Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt
Use this file to discover all available pages before exploring further.
Error Handling in Go
Go takes a unique approach to error handling. Instead oftry/catch exceptions found in languages like Java or Python, Go treats errors as values. This forces developers to handle errors explicitly where they occur, leading to more robust and predictable software.
The trade-off is real: Go code has more if err != nil blocks than most developers are used to. But the benefit is equally real — you always know where errors are handled (right there, at the call site), and you never have an exception flying across stack frames to a catch block you forgot existed. As Rob Pike put it: “Errors are values. Values can be programmed.”
The error Interface
In Go, an error is anything that implements the built-in error interface:
Basic Error Handling
Functions often return anerror as the last return value.
Creating Errors
You can create simple errors usingerrors.New or fmt.Errorf.
Custom Error Types
Sinceerror is an interface, you can create custom structs that implement it to provide more context.
Wrapping and Unwrapping Errors
Go 1.13 introduced error wrapping, allowing you to add context to an error while preserving the original error. This creates an “error chain” that can be inspected programmatically.Wrapping
Use the%w verb with fmt.Errorf to wrap an error:
Unwrapping (errors.Is and errors.As)
When you have a wrapped error, you need special functions to inspect the chain:
errors.Is(err, target): Checks if any error in the chain matches the target error.errors.As(err, &target): Finds the first error in the chain that matches the target type and assigns it.
err == io.EOF) only works for the outermost error. errors.Is traverses the entire chain.
Panic and Recover
Go has a mechanism for exceptional conditions calledpanic. It should be used sparingly, mostly for unrecoverable errors (like nil pointer dereferences or invariant violations).
Panic
panic stops the ordinary flow of control and begins panicking.
Recover
recover is a built-in function that regains control of a panicking goroutine. It is only useful inside a deferred function.
Sentinel Errors
Sentinel errors are pre-defined error values used for comparison. The standard library defines several:Best Practices
- Don’t Ignore Errors: Always check the
errreturn value. Use_only if you are absolutely sure the error is irrelevant (e.g.,fmt.Printlnreturn values). - Add Context: Wrap errors to provide “stack trace”-like context (e.g., “loading config: parsing json: syntax error”). Each layer should add what it was trying to do, not just repeat the error.
- Avoid Panic: Use errors for normal control flow. Reserve panic for truly exceptional, unrecoverable states (programming bugs, invariant violations).
- Use
%wfor wrapping,%vfor opaque errors: If callers should be able to inspect the underlying error witherrors.Isorerrors.As, use%w. If you want to hide implementation details, use%vto create an opaque error. - Define error types at package boundaries: Public APIs should document what errors they return (sentinel errors or custom types), so callers can handle them specifically.
Summary
- Errors are values in Go, implementing the
errorinterface. - Handle errors explicitly using
if err != nil. - Use
fmt.Errorfwith%wto wrap errors with context. - Use
errors.Isanderrors.Asto inspect wrapped errors. panicandrecoverare for exceptional circumstances, not normal flow control.
Interview Deep-Dive
When should you use `errors.Is` versus `errors.As`, and what is the difference between wrapping with `%w` versus `%v`?
When should you use `errors.Is` versus `errors.As`, and what is the difference between wrapping with `%w` versus `%v`?
Strong Answer:
errors.Is(err, target)traverses the error chain looking for an error that matches the target value. It is used for sentinel errors likeio.EOF,sql.ErrNoRows, or your ownErrNotFound. It compares by value equality (or by a customIsmethod if the error type defines one). Use it when you want to know “is this (or was this caused by) a specific known error?”errors.As(err, &target)traverses the chain looking for an error that can be assigned to the target type. It is used for custom error types when you need to extract structured information (like an HTTP status code, a retry-after duration, or a list of validation failures). Use it when you want to know “is there a specific error type in this chain, and if so, give me its fields.”%w(wrap) preserves the original error in the chain so callers can useerrors.Isanderrors.Asto inspect it.%vcreates a new error string that includes the original’s message but does NOT preserve the chain — callers cannot unwrap it. Use%wwhen you want callers to be able to match on the underlying error. Use%vwhen you want to hide implementation details (for example, you do not want callers depending on the specific database driver error your package returns).- This is an API design decision at package boundaries. If your package’s documentation says “returns ErrNotFound when the item does not exist,” you must wrap with
%wso callers can check witherrors.Is. If your package wants to abstract away the underlying storage, wrap with%vto make the error opaque.
ErrNotFound, ErrConflict, ErrValidation, ErrUnauthorized) and use errors.Is or errors.As in the HTTP handler to map them to status codes. The handler acts as a translation layer: if errors.Is(err, service.ErrNotFound) { w.WriteHeader(404) }. For richer errors, I define a type APIError struct { Code int; Message string; Details []FieldError } and use errors.As to extract it. The key principle is that the service layer should not know about HTTP — it returns domain errors. The handler translates domain errors to HTTP responses. This separation means the same service can be used by gRPC handlers, CLI tools, or message consumers, each mapping errors to their own protocol’s conventions.Go uses 'errors as values' instead of exceptions. Convince me this is better than try/catch, and then tell me where it falls short.
Go uses 'errors as values' instead of exceptions. Convince me this is better than try/catch, and then tell me where it falls short.
Strong Answer:
- Errors as values force explicit handling at every call site. You see exactly where each error is checked and what happens when it occurs. There are no invisible control flow jumps across stack frames. The code reads linearly: call, check, handle, continue. In Java, a method five levels deep can throw a checked exception that flies up to a catch block you wrote months ago, and the intermediate stack frames have no idea what happened.
- Errors as values are also composable. Since an error is just a value implementing an interface, you can wrap it with context at each layer, store it in data structures, pass it through channels, compare it programmatically, and make decisions based on its type. You can write functions that take errors as arguments and return errors. Rob Pike’s phrase “errors are values; values can be programmed” captures this elegance.
- Where it falls short: verbosity.
if err != nilblocks add roughly 30% more lines to Go code compared to equivalent Python or Java code. Every function call that can fail requires three lines of boilerplate. This is the most common criticism of Go’s error handling, and even the Go team has acknowledged it with failed proposals for error handling shorthand. Second, it is easy to accidentally ignore an error by not checking the return value. Linters likeerrcheckcatch this, but it is not enforced by the compiler. Third, the error wrapping chain can become unwieldy:"creating order: validating payment: connecting to stripe: dial tcp: i/o timeout"is useful for debugging but too detailed for end users. - The pragmatic view: in my experience, Go’s error handling produces more reliable production systems because errors are handled where they occur. The verbosity is a real cost but one that pays for itself when you are debugging a production incident at 2 AM and every function has explicit error handling with context.
defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic: %v", r) } }(). Importantly, recover only works when called directly inside a deferred function, not nested deeper. And you should almost never catch and suppress panics silently — log the stack trace with debug.Stack() so the programming error can be found and fixed.A junior developer returns a typed nil pointer as an error interface and it causes a bug in production. Explain exactly what happens at the memory level and how you prevent this.
A junior developer returns a typed nil pointer as an error interface and it causes a bug in production. Explain exactly what happens at the memory level and how you prevent this.
Strong Answer:
- The scenario: a function declares
var myErr *MyError(which is nil), does some processing, and returnsmyErras theerrorreturn type. The caller checksif err != niland getstrue, even thoughmyErris nil. This happens because the interface value now contains(*MyError, nil)— the type metadata pointer is set to*MyError’s type descriptor, while the data pointer is nil. The== nilcheck on an interface returns true only when BOTH the type and data are nil. - At the memory level, an interface is two pointers. A fully nil interface has both pointers as zero. When you assign a typed nil pointer to the interface, the first pointer gets set to the itab for
*MyErrorimplementingerror, and the second pointer is nil. Since the first pointer is non-zero, the interface is non-nil. - Prevention: the rule is simple — never return a typed nil variable as an interface. Always return the bare
nilliteral:if myErr != nil { return myErr }; return nil. This ensures the interface is fully nil (both pointers zero) when there is no error. - In code reviews, watch for functions that declare error variables of a concrete type and return them at the end. This is the pattern that produces the bug. Linters like
nilerrcan detect some forms of this, and thego vettool has checks for related issues. - A deeper prevention strategy is to never use concrete error types as local variables in the return path. Keep your error chain as
errorinterface throughout, and only construct concrete error types at the point of return:return &MyError{Code: 500, Msg: "failure"}.
errors.Is checks for value equality, so it works for sentinel errors (var ErrNotFound = errors.New("not found")). For custom struct types, errors.Is uses == comparison, which for pointer types checks pointer identity (same object in memory), not structural equality. So if you create &MyError{Code: 404} in two different places, errors.Is will not match them because they are different pointer values. To make errors.Is work with struct types, implement a custom Is(target error) bool method on your error type that does structural comparison. Alternatively, use errors.As when you want to match by type regardless of the specific value. In practice, use sentinel errors for well-known conditions (ErrNotFound, ErrTimeout) and errors.As when you need to extract data from a typed error.