Skip to main content

Command Palette

Search for a command to run...

Mastering Error Handling in Go - The Art of Simplicity and Clarity

“Don’t panic, just handle it.“ — This phrase beautifully sums up how Go treats errors.

Updated
11 min read
Mastering Error Handling in Go - The Art of Simplicity and Clarity

Error handling is one of the most defining design choices in Go. While other languages use exceptions, try/catch, or even silent failures, Go does something refreshingly different: it treats errors as regular values.

In this blog, we’ll dive deep into how Go handles errors, why this approach is brilliant, and how to master it in your code.

Understanding the error type

In Go, error is just an interface:

type error interface {
    Error() string
}

That’s it.
Any type that has an Error() method returning a string qualifies as an error.
This simplicity gives developers complete control over how errors are represented and handled.

Returning Errors from Functions

In Go, functions often return two values: one is the expected result, and the other is an error.

package main
import (
   "errors"
   "fmt"
)
func divide(a,b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("cannot divide 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)
}

Output:

Error: cannot divide by zero

This pattern → value, err := function() is everywhere in Go.

It may seem verbose at first, but it makes error handling predictable and explicit. You always know which operations can fail and how they’re handled.

Creating Custom Error Types

Go allows you to define custom errors for more meaningful messages.

type DivideError struct {
    Dividend float64
    Divisor float64
    Message string
}

func (e *DivideError) Error() string {
    return fmt.Sprintf("cannot divide %.2f by %.2f: %s", e.Dividend, e.Divisor, e.Message)
}

func divide(a,b float64) (float64, error) {
    if b == 0 {
        return 0, &DivideError{a,b,"division by zero"}
    }
    return a / b, nil
}

Wrapping and Unwrapping Errors

The problem wrapping solves

When an error occurs deep in your call stack, you usually want to:

  • Return the original error so callers can detect it, and

  • Add context so logs/debugging are helpful

This lets you do both: attach context while preserving the original error for detection.

How to wrap an error

Use fmt.Errorf with the %w verb:

import (
    "errors"
    "fmt"
)
func readConfig() error {
    return errors.New("file not found")
}
func load() error {
    if err := readConfig(); err != nil {
        return fmt.Errorf("load failed: %w", err) // wrap
    }
    return nil
}

%w creates a wrapped error that keeps the original error inside.

Important: use %w (not %v) when you want the error to be unwrappable.

How to check wrapped errors

Use errors.Is to compare against the original error (or sentinel)

err := load()
if errors.Is(err, errors.New("file not found")) { // this won't match sentinel created here
    // BAD: errors.New returns a new value; comparison fails.
}

A better pattern is to have a package-level sentinel or typed error:

var ErrNotFound = errors.New("file not found")

func readConfig() error {
    return ErrNotFound
}

// later
if errors.Is(err, ErrNotFound) {
    fmt.Println("config missing")
}

errors.Is(err, target) walks the chain of wrapped errors and returns true if any match.

Typed errors and errors.As

If you have richer error types (structs), use errors.As to extract them.

type MyErr struct {
    Code int
    Msg string
}
func do() error {
    return &MyErr{Code: 42, Msg: "boom"}
}
func (e *MyErr) Error() string {
    return fmt.Sprintf("Code %d: %s", e.Code, e.Msg)
}
func main() {
    err := do()
    var me *MyErr
    if errors.As(err, &me) {
        fmt.Println("Got MyErr with code:",me.Code)
    }
}

errors.As finds the first error in the chain that can be assigned to the provided type.

How errors.As() uses the pointer

errors.As() works by attempting a type assertion internally.

  • It checks the type of the incoming error (err).

  • If the types match (i.e., err is a *MyErr), errors.As() needs a way to write the actual error data into a variable you control.

  • By passing &me (which is a pointer to your pointer variable me), you are giving errors.As() the memory address where it should store the found error value.

Unwrapping manually

You can get the wrapped error directly.

w := fmt.Error("top %w", ErrorNotFound)
fmt.Println(errors.Unwrap(w)) // prints ErrNotFound

Now let's understand this wrapping and checking process with a complete example.

package main

import (
    "errors"
    "fmt"
)
var ErrorNotFound = errors.New("not found")

func readConfig() error {
    return ErrNotFound
}

func load() error {
    if err := readConfig(); err != nil {
        return fmt.Errorf("load config: %w", err)
    }
    return nil
}

func run() error {
    if err := load(); err != nil {
        return fmt.Errorf("run failed: %w", err)
    }
    return nil
}

func main() {
    if err := run(); err != nil {
        fmt.Println("error:", err) // prints full wrapped message
        if errors.Is(err,ErrNotFound) {
            fmt.Println("detect not found")
        }
    }
}

Expected output:

error: run failed: load config: not found
detect not found

You get the full human-readable context plus the ability to detect the original error programmatically.

Panic & Recover

What is Panic?

Panic stops normal execution and begins to unwind the stack, running deferred functions as it goes. It’s intended for programmer errors or truly unrecoverable conditions, not for normal error handling.

Example triggers:

  • index out of range

  • nil pointer deref

  • explicit panic(“bad state“)

Recover: how to catch a panic

recover() can regain control only if it is called inside a deferred function on the same goroutine where the panic occurred.

If recover() returns non-nil, the panic is stopped, and normal execution resumes after that deferred function returns.

Important: recover() does not work across goroutines.

Basic pattern: graceful recovery at the boundary

Common usage: put a defer + recover() near the top-level of goroutine (HTTP handler, worker, CLI entry point) to prevent a panic from crashing the whole process and to transform it into an error or log.

func safeRun() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recovered from panic:",r)
            // optional: log stack trace: debug.PrintStack()
        }
    }()

    // Code that might panic
    mustDo()
    fmt.Println("safeRun completed")
}

If mustDo() panics, the deferred function sees it and recovers. The program continues, and you can convert the panic to a meaningful error.

Now that we understand what panic and recover are and how to use them, the next question is when does recover() return nil and when does it not? Let's explore that.

When recover() returns non-nil

It returns non-nil only when:

  • a panic is currently in progress in the same goroutine,

  • and you call recover() inside a deferred function.

That’s the only time recover actually “catches” a panic.

package main
import "fmt"
func main() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered from panic:",r)
        }
    }()
    fmt.Println("Before panic")
    panic("Something went wrong!")
    fmt.Println("Before panic") // never runs
}

Output for above code

Before panic
Recovered from panic: Something went wrong!

Here, recover() returns "Something went wrong!" (the panic value), which is non-nil.

When recover() returns nil

There are three main cases when recover() returns nil

  1. No panic is happening

    If you just call recover() normally—not during a panic—it always returns nil.

     func main() {
         fmt.Println(recover()) // nil - no panic is happening
     }
    
  2. You call recover() outside a deferred function

    Recover only works when called from within a defer.

    If you call it outside, even if a panic happened earlier, it won’t stop the panic.

     func main() {
         panic("Boom!")
         fmt.Println(recover()) // never runs
     }
    

    This never runs—the program crashes because recover() must be inside a deferred function to intercept the panic.

  3. Recover is in a defer but not executed during that panic

If your function has multiple defers, only those active during the panic’s unwinding can recover it.

func demo() {
    defer fmt.Println("First defer") // this runs
    defer func() {
        fmt.Println("Second defer - no panic now")
        fmt.Println("Recover here:", recover()) // returns nil
    }()
}

func main() {
    demo()
}
Second defer - no panic now
Recover here: <nil>
First defer

There’s no active panic when the defer runs, so recover() returns nil.

The last declared defer function will execute first.

Easy way to remember

recover() works only once, and only when it’s called inside a deferred function during the panic unwind.

If you call it:

  • too early (no panic yet) → nil

  • too late (panic already recovered or finished) → nil

  • outside defer → nil

When to use panic + recover

  • Use panic for programmer mistakes or invariant violations, e.g., impossible states.

  • Use panic for unrecoverable system-level errors if the right thing is to crash, then rely on supervisors like systems or Kubernetes to restart.

  • Use recover near goroutine boundaries to prevent a single goroutine panic from killing the whole process and convert panic into an error if appropriate.

  • Don’t use panic for ordinary error handling—use error returns.

Real-World Use Cases for panic + recover

  1. Graceful recovery in long-running servers like web servers

    Scenario:

    In production systems, e.g., web APIs and background workers, a single handler might panic because of a bug, nil pointer, or invalid operation.
    If we let that panic crash the entire process, it would take down the whole server.

    So, we use recover in middleware to catch panics per request, log them, and keep the server alive.

  2. Handling unexpected panics in goroutines

    When you launch multiple goroutines, a panic in one goroutine doesn't automatically crash others, but if it happens in the main goroutine, it kills the program.

  3. In frameworks and libraries

    When you build reusable libraries or frameworks like a task runner, job scheduler, or web framework, you often want to protect user-defined callbacks.

    If user code panics, your library should recover and report it as an error instead of dying.

Go’s Error Handling Philosophy

Go’s designers had one goal in mind — clarity and simplicity.
By making errors explicit, they eliminated the hidden complexity that comes with exception handling systems.

You’ll often see this idiom:

if err != nil {
    return err
}

At first, it may seem repetitive, but this repetition ensures predictability.
No hidden stack traces. No silent skips. Just clean, explicit logic.

Go vs Other Languages

FeatureGoJava/Python/C++
MechanismReturn errors explicitlyExecptions
Error typeerror interfaceExecption classes
Control flowNormal executionTry/Catch blocks
PerformanceFast, no stack unwindingSlower due to exception overhead
DebhuggingSimple, explicitComplex trace handling

Before wrapping up this blog, let's compare this with an example in JavaScript and Go to understand the differences.

Let’s take the scenario of fetching user data and saving it to a database.

We’ll build a small imaginary flow:

  1. Fetch user data from an API

  2. Validate it

  3. Save it to a database

We’ll see how this looks in JavaScript (with try/catch) and Go (with explicit error handling).

Javascript Version (with async/await and try/catch)

async function fetchUser(id) {
  if (!id) throw new Error("Invalid user ID");
  // simulate API fetch
  return { id, name: "Alice" };
}

async function validateUser(user) {
  if (!user.name) throw new Error("User name missing");
  return user;
}

async function saveUserToDB(user) {
  if (user.name === "Alice") throw new Error("Duplicate user");
  return true;
}

async function main() {
  try {
    const user = await fetchUser(1);
    const validUser = await validateUser(user);
    await saveUserToDB(validUser);
    console.log("User saved successfully!");
  } catch (err) {
    console.error("Something went wrong:", err.message);
  }
}

main();

What’s happening here:

  • Errors are thrown and caught in a try/catch block.

  • If any function fails, JavaScript unwinds the stack until a catch is found.

  • It looks clean, but:

    • The error origin (which function failed) may not always be clear.

    • If you forget one await or don’t wrap async code properly, the error might bubble silently.

    • Handling specific errors like network errors vs. validation errors requires manual parsing of err.message.

Go Version (explicit error handling)

package main

import (
    "errors"
    "fmt"
)

type User struct {
    ID   int
    Name string
}

func fetchUser(id int) (User, error) {
    if id == 0 {
        return User{}, errors.New("invalid user ID")
    }
    return User{ID: id, Name: "Alice"}, nil
}

func validateUser(user User) error {
    if user.Name == "" {
        return errors.New("user name missing")
    }
    return nil
}

func saveUserToDB(user User) error {
    if user.Name == "Alice" {
        return errors.New("duplicate user")
    }
    return nil
}

func main() {
    user, err := fetchUser(1)
    if err != nil {
        fmt.Println("Error fetching user:", err)
        return
    }

    if err := validateUser(user); err != nil {
        fmt.Println("Validation error:", err)
        return
    }

    if err := saveUserToDB(user); err != nil {
        fmt.Println("Database error:", err)
        return
    }

    fmt.Println("User saved successfully!")
}

What's happening here:

  • Each function returns an error value—no throwing, no hidden jumps.

  • You handle the error right where it happens.

  • You can wrap or categorize errors easily using fmt.Errorf, errors.Is, or errors.As.

  • The flow is fully predictable—no stack unwinding, no implicit control flow.

Conclusion

Error handling in Go isn’t just about catching problems; it’s about designing programs that expect and gracefully manage them.

By making errors explicit:

  • Your code becomes more predictable

  • Your debugging becomes simpler

  • Your software becomes more robust

So the next time you write

if err != nil {
    return err
}

remember — this isn’t boilerplate.
It’s Go’s way of saying

Handle your problems, don’t hide them.

Go Deep with Golang

Part 4 of 11

Go beyond the basics! This series explores how Go works under the hood — from memory management to goroutines, channels, and design principles that make Go ideal for modern backend development.

Up next

Unlocking the Power of Structs and Interfaces in Go for Data Structuring

Discover How Structs and Interfaces Improve Data Handling in Go