Go Error Handling in 2026: Patterns, Wrapping and Technical Interview Questions
Go error handling patterns and best practices: sentinel errors, custom error types, errors.Is, errors.As, error wrapping with fmt.Errorf %w, and common interview questions.

Go error handling sets the language apart from most mainstream alternatives. Instead of exceptions and try/catch blocks, Go treats errors as values—returned explicitly from functions and checked at every call site. This deliberate design choice forces error handling into the foreground, making failure paths visible and testable.
Go has no exception mechanism. Every function that can fail returns an error as its last return value. This pattern makes the cost of ignoring errors visible at the source level—and makes error flows trivially testable.
The Error Interface and Why It Matters
The entire Go error system rests on a single interface defined in the standard library:
type error interface {
Error() string
}Any type implementing Error() string satisfies this interface. This simplicity drives composability: errors can be structs, wrappers, or anything else, as long as they produce a string representation.
A basic custom error type demonstrates the pattern:
type NotFoundError struct {
Resource string
ID string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s with ID %s not found", e.Resource, e.ID)
}This NotFoundError carries structured data. Callers can extract the resource name or ID programmatically instead of parsing a string—a critical advantage when building HTTP APIs or CLI tools that map errors to specific responses.
Sentinel Errors for Well-Known Conditions
Sentinel errors are package-level variables representing specific, well-known failure conditions. The standard library uses this pattern extensively: io.EOF, sql.ErrNoRows, os.ErrNotExist.
var (
ErrNotFound = errors.New("record not found")
ErrUnauthorized = errors.New("unauthorized access")
ErrConflict = errors.New("resource conflict")
)Sentinel errors work best when the caller only needs to know what failed, not why in detail. They signal a condition without carrying additional context. The Go convention is to prefix them with Err and keep the message lowercase without punctuation, following the Go Code Review Comments guideline.
Exporting sentinel errors creates a public API contract. Downstream packages will match against them with errors.Is, so renaming or removing a sentinel error is a breaking change. Use them sparingly—only for conditions callers genuinely need to branch on.
Error Wrapping with fmt.Errorf and the %w Verb
Error wrapping, introduced in Go 1.13, adds context to an error while preserving the original in a chain. The %w verb in fmt.Errorf creates this chain:
func (r *UserRepo) FindByID(ctx context.Context, id string) (*User, error) {
user, err := r.db.QueryContext(ctx, "SELECT * FROM users WHERE id = $1", id)
if err != nil {
return nil, fmt.Errorf("UserRepo.FindByID(%s): %w", id, err)
}
return user, nil
}The wrapped error retains the full chain. A caller three layers up can still match the original sentinel or extract the original type. Each layer adds context about where the error occurred without obscuring what happened.
A critical distinction: %w wraps (preserving the chain), while %v formats the error as a string and breaks the chain. Use %v deliberately when the original error should not leak through the abstraction boundary—for instance, when a repository wraps a database driver error that service-layer code should never inspect.
Inspecting Errors with errors.Is and errors.As
The errors package provides two functions for inspecting wrapped error chains, replacing direct comparison and type assertion.
errors.Is walks the chain looking for a specific error value:
func handleGetUser(w http.ResponseWriter, r *http.Request) {
user, err := userService.GetByID(r.Context(), chi.URLParam(r, "id"))
if errors.Is(err, ErrNotFound) {
// Matches even if ErrNotFound was wrapped multiple times
http.Error(w, "User not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(user)
}errors.As extracts a specific error type from the chain:
func errorMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if r := recover(); r != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func mapErrorToHTTP(err error) int {
var notFound *NotFoundError
if errors.As(err, ¬Found) {
// notFound.Resource and notFound.ID are available
return http.StatusNotFound
}
var validationErr *ValidationError
if errors.As(err, &validationErr) {
return http.StatusBadRequest
}
return http.StatusInternalServerError
}The key advantage over type assertions: both functions traverse the entire wrapped chain. A NotFoundError wrapped three times with fmt.Errorf and %w still matches.
Structured Error Handling in Layered Applications
Production Go applications typically organize errors across three layers: domain errors define business-level conditions, service errors add operational context, and handler or transport errors map them to external responses.
type DomainError struct {
Code string // machine-readable: "USER_NOT_FOUND", "INVALID_INPUT"
Message string // human-readable description
Err error // original cause
}
func (e *DomainError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %s: %v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
func (e *DomainError) Unwrap() error {
return e.Err
}The Unwrap method is what makes errors.Is and errors.As work through the chain. Any custom error type that wraps another error should implement it.
The service layer wraps domain errors with operational context:
func (s *UserService) Deactivate(ctx context.Context, userID string) error {
user, err := s.repo.FindByID(ctx, userID)
if err != nil {
return fmt.Errorf("deactivating user %s: %w", userID, err)
}
if user.Status == StatusInactive {
return &DomainError{
Code: "ALREADY_INACTIVE",
Message: fmt.Sprintf("user %s is already inactive", userID),
}
}
return s.repo.UpdateStatus(ctx, userID, StatusInactive)
}Ready to ace your Go interviews?
Practice with our interactive simulators, flashcards, and technical tests.
Common Interview Questions on Go Error Handling
Technical interviews for Go positions frequently test error handling knowledge. These questions appear regularly in screening rounds and are covered in depth in Go error handling interview questions.
Why does Go use error values instead of exceptions?
Exceptions create invisible control flow. A function that throws transfers control to an unknown catch site, potentially many stack frames away. Go error values make every failure path explicit in the function signature. The caller decides immediately how to handle the error—retry, wrap, log, or propagate. This eliminates the "surprise throw" problem and makes error flows readable without tooling support.
What is the difference between %w and %v in fmt.Errorf?
%w wraps the error, preserving the chain for errors.Is and errors.As. %v formats the error as a string, producing a new error with no chain link back to the original. Use %w when callers should be able to inspect the cause; use %v when the cause is an implementation detail that should not leak through an abstraction boundary.
When should a function return an error vs. panic?
Panic is reserved for truly unrecoverable situations: programming errors like index out of bounds, nil pointer dereferences, or violated invariants that indicate a bug. Recoverable failures—network timeouts, missing records, invalid input—return errors. A useful rule: if the condition can occur during normal operation, it returns an error. If it means the program has a bug, it panics.
How does the Unwrap method affect error chain inspection?
When a custom error type implements Unwrap() error, the errors.Is and errors.As functions follow that method to traverse the chain. Without Unwrap, the chain stops at that error. Since Go 1.20, errors can also implement Unwrap() []error to return multiple wrapped errors, enabling tree-shaped error chains for cases like aggregating validation errors.
Error Handling Patterns to Avoid
Several anti-patterns appear frequently in Go codebases and in code review sessions:
Ignoring errors silently. The blank identifier makes this syntactically easy but semantically dangerous:
// anti-pattern: silent error swallowing
result, _ := riskyOperation()Every ignored error should be a deliberate, documented choice—never a shortcut.
Logging and returning. This pattern produces duplicate log entries and confuses the caller who also handles the error:
// anti-pattern: double handling
if err != nil {
log.Printf("operation failed: %v", err) // logged here
return err // AND returned to caller who may log again
}The fix: either handle the error (log it, return a default, retry) or propagate it. Not both.
String matching for error classification. Checking err.Error() output with strings.Contains is fragile. Error messages are not API contracts—they can change between library versions. Use errors.Is for sentinel values and errors.As for types.
Wrapping errors that cross goroutine boundaries without synchronization. When multiple goroutines produce errors concurrently, use errgroup.Group from the golang.org/x/sync package or a similar synchronization mechanism. Directly appending to a shared slice without a mutex creates data races.
Error Handling with errgroup for Concurrent Operations
The errgroup package from golang.org/x/sync provides a clean pattern for collecting errors from concurrent goroutines:
func (s *OrderService) ProcessBatch(ctx context.Context, orderIDs []string) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10) // limit concurrent goroutines
for _, id := range orderIDs {
g.Go(func() error {
if err := s.processOrder(ctx, id); err != nil {
return fmt.Errorf("processing order %s: %w", id, err)
}
return nil
})
}
// Returns the first non-nil error and cancels remaining work
return g.Wait()
}errgroup.WithContext cancels the derived context when any goroutine returns an error, signaling the others to stop. This avoids wasted work and provides a single, wrapped error to the caller.
Go 1.24 (February 2025) did not change the errors package. The error handling API has been stable since Go 1.13, with the Go 1.20 addition of multi-error unwrapping via Unwrap() []error. Current proposals for improved error handling syntax remain under discussion in the Go issue tracker.
Conclusion
- Treat errors as values: return them explicitly and handle them at every call site instead of relying on hidden exception mechanisms
- Use sentinel errors (
var ErrX = errors.New(...)) only for conditions callers need to branch on, and treat them as public API - Wrap errors with
fmt.Errorfand%wto add context while preserving the chain; use%vto intentionally break the chain at abstraction boundaries - Prefer
errors.Isover==anderrors.Asover type assertions—both traverse the full wrapped chain - Implement
Unwrap() erroron custom error types so chain inspection works correctly - Handle or propagate—never both. Logging an error and returning it produces duplicate noise
- Use
errgroupfor concurrent error collection instead of manual goroutine synchronization - Keep error messages lowercase without punctuation, following Go conventions
Start practicing!
Test your knowledge with our interview simulators and technical tests.
Tags
Share
Related articles

Go Design Patterns: Essential Patterns and Interview Questions for Go Developers
Master Go design patterns including Functional Options, Strategy, Factory, and Observer. Practical code examples, idiomatic best practices, and common interview questions for Go developers.

Top 25 Go Interview Questions: Complete Developer Guide
Ace your Go interviews with the 25 most asked questions. Master goroutines, channels, interfaces, concurrency patterns with practical code examples.

Go Technical Interview: Goroutines, Channels and Concurrency
Go interview questions on goroutines, channels, and concurrency patterns. Code examples, common pitfalls, and expert-level answers to prepare for Go technical interviews in 2026.