Articles
Go Error Handling Interview Questions: Wrapping, Joining, and errors.Is
Practice Go error-handling interviews with wrapping, errors.Join, errors.Is, typed matching, cleanup failures, API boundaries, and executable test cases.
In a Go error-handling interview, separate the message a person reads from the error relationships a caller inspects. Use wrapping to retain a cause, joining to retain multiple failures, and errors.Is to test a documented condition without parsing text. Then explain which details your API deliberately exposes and what callers are allowed to depend on.
The language and library claims below come from official Go documentation. The error trees, cleanup policy, and test cases are original practice exercises, not reports of an employer's interview.
What changes when you replace %v with %w?
Both verbs can include an error's text in a formatted message. In fmt.Errorf, %w additionally creates an inspectable wrapping relationship. %v formats the value without establishing that relationship. The distinction matters when a caller wants to recognize the original condition beneath added context. fmt.Errorf documentation
Here is an original exercise using a sentinel owned by the application:
var ErrQuota = errors.New("quota exceeded")
func reserve(remaining int) error {
if remaining <= 0 {
return ErrQuota
}
return nil
}
func submit(remaining int) error {
if err := reserve(remaining); err != nil {
return fmt.Errorf("submit export: %w", err)
}
return nil
}
The caller can test errors.Is(submit(0), ErrQuota). Comparing submit(0) == ErrQuota fails because the returned value is a wrapper. Searching for the substring “quota” is also the wrong contract: wording could change or appear in an unrelated failure.
For a useful test, change the wrapper's text while retaining %w. Classification should still pass. Next, change %w to %v; the message remains plausible, but the classification test should fail. This pair demonstrates precisely what wrapping adds.
Do not manufacture a fresh errors.New("quota exceeded") at every layer and expect matching text to establish identity. Give the application one documented sentinel, or use an error type when callers need structured details. The choice should follow the caller's decision, not the convenience of one print statement.
When should you use Is rather than As?
errors.Is answers whether an error tree matches a particular target. errors.As finds a matching assignable type and supplies the matched value. Both inspect wrappers rather than only the outermost error. The standard library also supports custom matching methods, so the operation is richer than one direct equality comparison or type assertion. Go errors package
In the quota exercise, Is is enough if callers only need to stop submitting. A typed error is appropriate if they must inspect the resource name and available capacity. Avoid turning every log field into a public type field; each exposed field becomes something callers may use.
type QuotaError struct {
Resource string
Remaining int
}
func (e *QuotaError) Error() string {
return fmt.Sprintf("%s quota exhausted", e.Resource)
}
func (e *QuotaError) Is(target error) bool {
return target == ErrQuota
}
A wrapped *QuotaError can match the sentinel through Is and provide details through As. For example, declare var detail *QuotaError, then call errors.As(err, &detail). The extra pointer allows As to assign the discovered pointer value into detail; it is not arbitrary punctuation.
Current Go documentation also provides errors.AsType, introduced in Go 1.26. This article uses As in the core exercise to remain compatible with Go 1.20 and later, the baseline needed for joining. If your interview environment supports the newer API, explain the same matching semantics rather than treating shorter syntax as a different error model. Go errors type inspection
A direct assertion such as err.(*QuotaError) only examines the outer value. Put one contextual wrapper around the error and it stops answering the intended question. That is a compact follow-up test worth demonstrating.
How do joined errors change the shape of the problem?
A wrapper around one cause forms a chain. A value containing multiple underlying errors forms a tree. Go 1.20 added errors.Join and support for multiple %w operands in fmt.Errorf; inspection can traverse multiple branches. This is useful when two independent operations fail and neither failure should disappear. Go 1.20 release notes
Extend the exercise with an independent cleanup failure:
var ErrCleanup = errors.New("cleanup failed")
func combinedFailure() error {
work := fmt.Errorf("reserve: %w", ErrQuota)
cleanup := fmt.Errorf("release temp file: %w", ErrCleanup)
return errors.Join(work, cleanup)
}
Now both errors.Is(combinedFailure(), ErrQuota) and the corresponding check for ErrCleanup succeed. That does not mean the two conditions have identical consequences. The application still needs a policy: perhaps quota exhaustion is an expected rejection while cleanup failure requires an operational alert.
If two branches contain the same error type, As returns the first matching result according to traversal order. It does not collect every match. Do not use it as an aggregation API when the interviewer asks for all failed records. Keep an explicit result list, or define a separate traversal with a clearly documented ordering contract.
Joining also does not deduplicate semantically identical errors for your application. If two workers report the same record failure, decide whether duplicate observations should remain in diagnostics. Preserve record identifiers separately from human messages so that deduplication does not rely on wording.
Why does errors.Unwrap return nil for a joined error?
The convenience function errors.Unwrap handles an Unwrap() error method. It does not traverse the Unwrap() []error method used for multiple children. Therefore, a manual loop repeatedly calling errors.Unwrap can miss an entire joined tree even though Is and As find its causes. errors.Unwrap reference
Use this distinction as a diagnostic question: “Does a nil result mean there are no inspectable causes, or only that this helper does not expose multiple children?” In the joined example, the second explanation is correct.
You rarely need a homemade traversal merely to classify a known condition. Prefer Is for the decision you actually need. If the task explicitly asks for a full diagnostic tree, handle both unwrapping shapes, preserve branch order, and state whether arbitrary custom implementations can form cycles. Do not claim a simple recursive function is safe for every user-defined error graph.
How do you preserve a failure from deferred cleanup?
A common exercise asks you to return the main operation's error while also checking a deferred close. Returning only the operation error can discard a failed cleanup. Overwriting it unconditionally can hide the original failure instead. The right answer starts with an explicit policy.
For this original example, retain both failures if both occur, and add cleanup context only when close actually fails:
func runWithCleanup(work func() error, closeFn func() error) (err error) {
defer func() {
if closeErr := closeFn(); closeErr != nil {
err = errors.Join(err,
fmt.Errorf("cleanup: %w", closeErr))
}
}()
return work()
}
The named result is updated by the deferred function before the caller receives it. A fresh local variable that shadows err inside the closure would not accomplish the same thing. Keep the example small enough that the assignment target is unambiguous.
This is not a universal recommendation to join every close failure. Closing a read-only stream, flushing a buffered writer, rolling back a transaction, and releasing a lock have different consequences. Explain the resource's contract and what the caller can do with the additional information.
| Work result | Cleanup result | Exercise result | Caller interpretation |
|---|---|---|---|
| Success | Success | nil | Completed under the stated contract |
| Failure | Success | Work error | Original operation failed |
| Success | Failure | Wrapped cleanup failure | Work returned success, cleanup did not |
| Failure | Failure | Joined error | Preserve and inspect both branches |
Test every row using injected functions rather than relying on a real disk to fail at exactly the right moment. Also verify cleanup executes once. That catches lifecycle defects independently of classification mistakes.
Does wrapping expose too much of your implementation?
Wrapping can make a lower-level condition part of your API's observable behavior. The Go team's error guidance explicitly treats this as an abstraction decision: exposing a cause lets callers depend on it. Working with errors in Go
Suppose a repository currently uses a database driver whose “not found” error reaches callers through %w. If those callers inspect the driver sentinel, replacing the storage engine can break behavior even when the repository's function signature stays unchanged.
A deliberate boundary might translate that condition to an application-owned ErrMissing. Keep detailed diagnostics through your logging or tracing policy while returning a stable public classification. Alternatively, document that the underlying error is part of the contract. Neither choice should happen accidentally because %w looked like a better %v.
For the quota example, state whether Remaining is a reliable decision input or diagnostic context captured at the time of failure. In a concurrent system, that number can become stale immediately. A retry decision may require a fresh operation, not arithmetic on an old error field.
Likewise, an error matching a timeout does not automatically authorize retrying a non-idempotent request. Classification describes an observed failure; retry safety depends on whether the operation might already have taken effect. Keep those two arguments separate in your explanation.
Which interview traps deserve a focused test?
Begin with identity and tree structure. Assert that wrapping preserves the sentinel, text-only formatting does not, and a join preserves both independent targets. For a typed cause, verify As still succeeds beneath two contextual wrappers. Change message text and confirm classification remains stable.
Then test the nil boundary. errors.Join discards nil inputs and returns nil when all inputs are nil. That is different from an interface containing a typed nil pointer, which is not a nil interface. Avoid returning a typed nil error value as the success path. Go FAQ on nil errors
For the cleanup helper, supply a counter and scripted return values. Assert both causes in the two-failure case; asserting only that the result is non-nil would allow an implementation that loses either branch to pass.
End by describing the supported toolchain. The exercise needs Go 1.20 or later because it uses Join; AsType is an optional newer follow-up. Version awareness is especially useful when a candidate remembers correct modern syntax that the provided environment cannot compile.