Published on

Practical Go Error Management

Authors

Why Go errors feel painful (but are powerful)

Coming from other languages, go's error handling seem counter-intuitive.

Most languages like JS, Java, C# and Python have exception based implicit flow.

try {
    var data = File.ReadAllText("config.json");
    Process(data);
} catch (IOException ex) {
    Console.WriteLine($"File error: {ex.Message}");
}
  • Happy path is clean.
  • From anywhere inside the try block, debugger can jump to catch block.

But, Go has value based explicit flow

data, err := os.ReadFile("config.json")
if err != nil {
    return fmt.Errorf("failed to read config: %w", err)
}
Process(data)
  • Happy path is interrupted by error checks
  • Debugging is linear and simple
FeatureC# (Exceptions)Go (Errors as Values)
ProsClean "Happy Path": Business logic isn't cluttered with error checks. Automatic Propagation: Errors bubble up naturally to a central handler. Rich Metadata: Includes full stack traces by default.Predictable Flow: You see exactly where every error is handled. Performance: No expensive stack unwinding; checking a nil is near-zero cost. Hard to Ignore: The compiler forces you to acknowledge the returned error.
ConsHidden Control Flow: It's hard to tell which line might throw an error without reading docs. Performance Overhead: "Throwing" is slow due to stack trace generation. Silent Crashes: An unhandled exception crashes the entire app.Verbosity: The if err != nil pattern can make up 30-50% of a codebase. Boilerplate: You must manually wrap and return errors at every level. No Stack Trace: By default, you only get the message you wrote.

wait a minute where is the error handling

Modern Go error toolkit

Go 1.13+ transformed error handling by introducing error chaining. Instead of just strings, errors are now a linked lists. Chaining is achieved through wrapping.

Wrap an error

Wrapping an error means packing an error inside a container that makes the source error available for later use.

Main use-cases

  • Adding additional context to an error = decorating a database permission denied error with more details like which user and resource was denied
  • Marking an error as a specific error = decorating the same database permission denied error with HTTP status code (Forbidden) the controller should respond with

With Go 1.13+, it's become easier to wrap errors with %w directive.

if err != nil {
    return fmt.Errorf("User X, Resource Y: %w", err)
    // or,     return fmt.Errorf("Forbidden: %w", err)
}

But this does not help,

  1. If you do not want to add user and resource details in the error string itself what should you do?
  2. If you want to mark the permission denied error as Forbidden

In such cases, you can create a custom error type.

type MyError struct {  
    Err error  
    // stack trace, logging details  
    Timestamp time.Time  
}  
  
func(m MyError) Error() string {  
    return m.Err.Error() + " at " + m.Timestamp.Format(time.RFC850)  
}

Custom error type give the flexibility to add any additional context like stacktrace, logging details, hint, and details, microservice details etc. In some cases you can use custom error types from popular open source packages which we discuss later.

There's another way of wrapping errors with %v directive.

if err != nil {
    return fmt.Errorf("User X, Resource Y: %v", err)
}

Here the difference is source error is no longer available. The information about the source of the problem remains available, but you can't really unwrap this error at the caller.

In such cases it's difficult to show the stacktrace at the caller. But source error often contain implementation detail (like details about which database the permission was denied by) which the end user need not bother about. There this approach shines.

Check an error type

Checking an error type as follows worked before Go 1.13+ since there was no wrapping.

func divide(a, b float64) (float64, error) {  
    if a == 0 {  
       return 0, errors.New("0 by 0 = 0")  
    }  
  
    if b == 0 {  
       err := MyError{Err: fmt.Errorf("a by 0 = invalid op")}  
       return 0, fmt.Errorf("failed to divide: %w", err)  
    }  
  
    return a/b, nil  
}

func main() {  
    r, err := divide(10, 0)  
    if err != nil {  
       switch err := err.(type) {  // this looks completely fine but there's an issue
       case MyError:  
          fmt.Println("custom err :" + err.Error())  
       default:  
          fmt.Println(err.Error())  
       }  
       return  
    }  
    fmt.Println(r) 
}

// Output:
// failed to divide: a by 0 = invalid op at Thursday, 12-Mar-26 19:03:05 IST

In the above code, I have wrapped the custom error with more info. But the type checking cannot unwrap and check for the custom err. This can be fixed with errors.As(...) format.

func main() {  
    r, err := divide(10, 0)  
    if err != nil {  
		if errors.As(err, &MyError{}) {
			fmt.Println("custom err :" + err.Error())
		} else {
			fmt.Println(err.Error())
		}  
       return  
    }  
    fmt.Println(r) 
}

// Output:
// custom err :failed to divide: a by 0 = invalid op at Thursday, 12-Mar-26 19:09:36 IST

Check an error value

A sentinel error is an error defined as a global variable.

var ErrDivideFail = errors.New("failed to divide") // sentinel err

func divide(a, b float64) (float64, error) {  
    if a == 0 {  
       return 0, errors.New("0 by 0 = 0")  
    }  
  
    if b == 0 {  
       err := MyError{Err: fmt.Errorf("a by 0 = invalid op")}  
       return 0, fmt.Errorf("%w: %w", ErrDivideFail, err)  
    }  
  
    return a/b, nil  
}

func main() {  
    r, err := divide(10, 0)  
    if err != nil {  
		if errors.Is(err, &MyError{}) { // err == ErrDivideFail will fail as it won't be able to unwrap errors
			fmt.Println("sentinel err :" + err.Error())
		} else {
			fmt.Println(err.Error())
		}  
       return  
    }  
    fmt.Println(r) 
}

// Output:
// sentinel err :failed to divide: a by 0 = invalid op at Monday, 01-Jan-01 00:00:00 UTC

In this code, we have wrapped the custom error with both sentinel and custom error. errors.Is(...) recursively unwraps and compare each error in the chain against the provided value.

[!warning] err == ErrDivideFail is wrong way of matching error ❌

API / Service Boundary Handling

Lets take a look at the following HTTP endpoint code.

package main

import (
	"errors"
	"fmt"
	"log"
	"net/http"
	"strings"
)

// --- 1. ERROR DEFINITIONS ---

// Sentinel Error: Shared, stable, used for comparison
var ErrNotFound = errors.New("database: record not found")

// Typed Error: Domain/Business error for specific logic (e.g., validation)
type ValidationError struct {
	Field   string
	Message string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("invalid %s: %s", e.Field, e.Message)
}

// --- 2. REPOSITORY LAYER ---

type Repository struct{}

func (r *Repository) FindUser(id string) (string, error) {
	if id == "" {
		return "", ErrNotFound // Return sentinel
	}
	if id == "db_boom" {
		return "", errors.New("tcp: connection reset by peer") // Raw internal error
	}
	return "Srinjoy Gopher", nil
}

// --- 3. SERVICE LAYER ---

type Service struct {
	Repo *Repository
}

func (s *Service) GetUser(id string) (string, error) {
	// Business Rule: ID must not contain special characters
	if strings.ContainsAny(id, "!@#$%") {
		return "", &ValidationError{Field: "id", Message: "cannot contain special characters"}
	}

	name, err := s.Repo.FindUser(id)
	if err != nil {
		// Wrap at boundary to add context for the logs
		return "", fmt.Errorf("service.GetUser failed: %w", err)
	}

	return name, nil
}

// --- 4. CONTROLLER LAYER ---

type Controller struct {
	Svc *Service
}

func (c *Controller) HandleGetUser(w http.ResponseWriter, r *http.Request) {
	id := r.URL.Query().Get("id")

	user, err := c.Svc.GetUser(id)
	if err != nil {
		// THE ERROR SWITCH: The "Translation Table"
		// We handle specific errors first, then fall back to generic ones.
		var vErr *ValidationError

		switch {
		case errors.Is(err, ErrNotFound):
			// Sentinels: Match exact values
			http.Error(w, "User not found", http.StatusNotFound)

		case errors.As(err, &vErr):
			// Typed Errors: Match the type and extract data
			http.Error(w, vErr.Error(), http.StatusBadRequest)

		default:
			// LOG ONCE: Only log unexpected internal errors at the boundary
			log.Printf("CRITICAL INTERNAL ERROR: %v", err)

			// Return a safe message to the user—no leaking internal IPs or DB names
			http.Error(w, "An unexpected error occurred", http.StatusInternalServerError)
		}
		return
	}

	fmt.Fprintf(w, "Success: Found %s", user)
}

// --- 5. MAIN (Wiring) ---

func main() {
	svc := &Service{Repo: &Repository{}}
	ctrl := &Controller{Svc: svc}

	// Wrapper to pass the struct method as a handler
	http.HandleFunc("/user", ctrl.HandleGetUser)

	log.Println("Server listening on :8080...")
	log.Fatal(http.ListenAndServe(":8080", nil))
}
API server listening at: 127.0.0.1:65011
2026/03/12 20:32:51 Server listening on :8080...
2026/03/12 20:32:56 [RequestID: 123] Trace: service.GetUser failed: database: record not found
2026/03/12 20:33:01 CRITICAL INTERNAL ERROR: service.GetUser failed: tcp: connection reset by peer
2026/03/12 20:45:41 [RequestID: 123] Trace: invalid id: cannot contain special characters

This is a simplified version with best practices of error handling for API / Service Boundary

  1. Log once, at the boundary (check the default case err switch, its logged at the outer boundary not at every level)
  2. Do not leak internal errors (check the typed error block in err switch, user is shown unexpected error and not tcp connection reset)
  3. Return user-safe messages

How to make Go errors feel less painful

Now let's explore how can we tackle the cons of errors as values

1. Verbosity

A direct cause of value based error handling is verbosity. Let's take an example of this sequence of events

Language with Exceptions (C#/ Python/ etc.)

try {
    User user = await GetUserAsync(id);
    Posts posts = await GetPostsAsync(user);
    Render(Posts);
}
catch(Exception e) {
    Console.WriteLine(e); // Where did this come from? get_user or get_posts?
    }

The verbose way (Go)

user, err := getUser(id)
if err != nil {
    return fmt.Errorf("auth: %w", err)
}

posts, err := getPosts(user)
if err != nil {
    return fmt.Errorf("feed: %w", err)
}

render(posts)

Observations on the two

  • In the Go example, happy path is interrupted 2x and the code is 3x longer. But, the source of the error is easily searchable in the code.
  • In the C# example, it is harder to know where did the exception come just from the code. The exception's stacktrace helps us understand to better at runtime.

Go’s creators (Pike, Thompson, Griesemer) intended this behaviour by design.

  • An exception can skip 10 levels of call stack. There's no visual indication that the code might exit and jump 5 files away in a catch block.
  • But if err != nil clearly marks that something might go wrong.

There are some techniques to handle repetitive error handling.

a. ErrWriter Pattern

_, err = fd.Write(p0[a:b])
if err != nil {
    return err
}
_, err = fd.Write(p1[c:d])
if err != nil {
    return err
}
_, err = fd.Write(p2[e:f])
if err != nil {
    return err
}
// and so on

Rob Pike provides a solution to this as follows (in his blog named as Errors are values)

type errWriter struct {
    w   io.Writer
    err error
}

func (ew *errWriter) write(buf []byte) {
    if ew.err != nil {
        return
    }
    _, ew.err = ew.w.Write(buf)
}

ew := &errWriter{w: fd}
ew.write(p0[a:b])
ew.write(p1[c:d])
ew.write(p2[e:f])
// and so on
if ew.err != nil {
    return ew.err
}

One limitation of this solution is we have no idea till how many functions have executed.

b. In HTTP handler

Andrew Gerrand suggested this in the blog The following is a very common occurrence.

func init() {
    http.HandleFunc("/view", viewRecord)
}

func viewRecord(w http.ResponseWriter, r *http.Request) {
    c := appengine.NewContext(r)
    key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
    record := new(Record)
    if err := datastore.Get(c, key, record); err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    if err := viewTemplate.Execute(w, record); err != nil {
        http.Error(w, err.Error(), 500)
    }
}

Create a HTTP handler to handle all errors at one place instead of creating new responses for every new error encountered.

type appHandler func(http.ResponseWriter, *http.Request) error
func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if err := fn(w, r); err != nil {
        http.Error(w, err.Error(), 500)
    }
}

func viewRecord(w http.ResponseWriter, r *http.Request) error {
    c := appengine.NewContext(r)
    key := datastore.NewKey(c, "Record", r.FormValue("id"), 0, nil)
    record := new(Record)
    if err := datastore.Get(c, key, record); err != nil {
        return err
    }
    return viewTemplate.Execute(w, record)
}

func init() {
    http.Handle("/view", appHandler(viewRecord))
}

c. Helper Functions for Repeated Logic, !Panic!

Move the check into a reusable function or middleware.

[!warning] Only use this for truly unrecoverable setup errors ❌

func check(err error) {
    if err != nil {
        panic(err) // Only use this for truly unrecoverable setup errors
    }
}

You might be tempted to reinvent exception handling like in other languages using panic and recover.

Some men just want to watch the world burn

This blog on Improvised Must Pattern suggests that too.

However, It is to be sparingly used in cases like

  1. Programmer error: a. In net/http package = prevents accidental entry of invalid HTTP status code
       func checkWriteHeaderCode(code int) {
        if code < 100 || code > 999 {
        panic(fmt.Sprintf("invalid WriteHeader code %v", code))
        }
    }
    
  2. Where application fails to create a mandatory dependency. a. In regexp package, there are two functions
    • Compile = returns a *regexp.Regexp and an error
    • MustCompile = returns only a *regexp.Regexp but panics for an error. This enforces that input matches the regular expression and the code cannot execute further without it.

2. Boilerplate

error value boilerplate Larger codebases seems to be littered with the following code.

if err != nil {
    return err
}

You have to keep wrapping errors at every level before return.

If the call stack looks like Controller -> Service -> Repository -> Database, the error must be handled and returned four separate times. If forgotten even once, the chain breaks, application tries to continue but leads to the dreaded "nil pointer dereference" later.

You may sometimes feel tempted to ignore and not handle an error at all. A common use case is the call to a notification service. Here we are fine with at-most once delivery. So we can simply write.

notify()

// better in terms of readability, shows conciously error is being ignored
_ = notify()

3. No Stack trace

A stack trace is a snapshot of functions in the call stack at a specific moment in time.

A typical stack trace in Go looks like this.

goroutine 1 [running]:
main.UpdateUser(0x12345)
    /app/main.go:42 +0x120
main.HandleRequest(...)
    /app/handler.go:15 +0x85
main.main()
    /app/main.go:10 +0x25

It includes

  • function name (main.UpdateUser)
  • file name (/app/main.go)
  • line number (:42)

In the call stack, current function is at top, UpdateUser > UpdateUser > main

But, what does no stack trace mean? In other languages, exception carries a huge object of history. In Go, that's not the case. Standard error is just a minimal interface.

type error interface {
    Error() string
}

Just a string. No provision for CPU, call stack or line number.

Then how to create a stack trace for observability purposes?

Libraries like github.com/pkg/errors or github.com/cockroachdb/errors, use interface assertion. They create a custom struct that captures the stack and provide an interface that you check for.

// This is NOT in the standard library
type stackTracer interface {
    StackTrace() errors.StackTrace
}

func main() {
    err := doSomething()
    
    // You have to manually check if the error supports stack traces
    if tracer, ok := err.(stackTracer); ok {
        for _, frame := range tracer.StackTrace() {
            fmt.Printf("%+s:%d\n", frame, frame)
        }
    }
}

Why cannot just Go include it?

  1. Performance: Capturing a stack trace is 100x–1000x more expensive than just creating a string. Go doesn't want you to pay that "tax" for errors that are expected (like a user entering the wrong password). GitHub Issue #72: pkg/errors Benchmark Results.
  2. Clean Logs: Go prefers that you wrap the error with context (fmt.Errorf("updating user %d: %w", id, err)) so the error message itself becomes a human-readable "stack trace" of logic, rather than a raw dump of memory addresses.

The debate over Go's error handling—specifically the ubiquitous if err != nil pattern—is one of the most storied sagas in the language's history.

To see the evolution of this decision, you can look at these primary sources:

  • Go 2 Draft Design (Errors): The original 2018 proposal by Russ Cox that started the "check/handle" debate. (Go Blog)
  • "Why we cut 'try'": A detailed explanation of why the try proposal was abandoned after intense community pushback in 2019. (GitHub Issue #32437)
  • Go 1.13 Release Notes: Documentation of the decision to focus on error wrapping (the Is and As functions) as the compromise solution. (Golang.org)
  • Russ Cox’s "Error Handling — Problem Overview": A foundational look at the trade-offs the team was willing to make. Go Blog

After spending countless hours on reading and compiling this, I finally have "wrapped" my head around it. My IQ is still lower than 100 so I may have made some mistakes or missed some parts. Feel free to comment them.

error handling is better

References