Error Handling And Best Practices (Dealing With Errors, Panics, Recover)

Overview

Go’s approach to error handling is simple but powerful, emphasizing clear control flow over error-prone constructs like try/catch. In this chapter, you will learn how to effectively manage errors in Go using idiomatic patterns. The chapter covers basic error handling, creating and returning errors, and best practices such as wrapping errors for better context. You will also explore advanced topics like custom error types, sentinel errors, error chain traversal, modern error inspection with errors.Is() and the generic errors.AsType() added in Go 1.26, custom unmarshal error handling, and the use of Go’s panic and recover for managing unexpected failures. By the end, you’ll have a deep understanding of how Go’s error model leads to more reliable and maintainable code, while learning when and how to use recovery mechanisms for graceful error handling.

No Try/Catch

In Go there is no try/catch paradigm. Errors are return values that need to be handled like any other value returned from a function call.


package main

import (
	"log"
	"os"
)

func main() {
	_, err := os.Open("/path/to/some/content.file")
	if err != nil {
		// open /path/to/some/content.file: no such file or directory
		log.Fatal(err)
	}
}

If the err is nil, then you know the call succeeded, otherwise something went wrong.

Gracefully Handle Errors

Because errors are values, we can handle them gracefully in our programs.

In this example, we look to see if a config file was provided by the user. If not, we’ll fall back to a default config.

This is an example of even though we received an error, we could gracefully recover and plan for an alternative action.


type config struct {
	Backup bool `json:"backup"`
	Debug  bool `json:"debug"`
}

var f io.Reader
var err error

// try to read a file
f, err = os.Open("./config.json")
if err != nil {
	// Fall back to a default config. Stream it as a reader.
	f = strings.NewReader(`{"backup":false,"debug":true}`)
}

c := config{}

if err := json.NewDecoder(f).Decode(&c); err != nil {
	log.Fatal(err)
}

fmt.Printf("%+v\n", c)

Output:


{Backup:false Debug:true}

Creating Errors

There are two “built-in” ways to create errors in the standard libary.

// in the `errors` package
errors.New("a message")
// in the `fmt` package
fmt.Errorf("a %s message", "formatted")

Returning Errors

Errors are returned from functions just like any other type.


func boom() error {
	return errors.New("boom!")
}

func greetOrBoom() (string, error) {
	return "hello", errors.New("boom!")
}

Sentinel Errors

Sentinel errors use a specific value to signify that no further processing is possible.

Examples in the standard library include io.EOF and sql.ErrNoRows.

io.EOF indicates that you have reached the end of a file, which isn’t really an error; we expect it to happen when we want to read the entire file.

Error values are just variables:

// EOF is returned when the end of the file has
// been reached.
var EOF = errors.New("EOF")

Sentinel Example

In this code, we are opening a file and reading the contents by using the io.Reader interface. We expect to see an io.EOF error. We use this sentinel error to signal that we have completed our task successfully.


func main() {
	f, err := os.Open("./README.md")
	if err != nil {
		log.Fatal(err)
	}

	buff := make([]byte, 10)

	for {
		i, err := f.Read(buff)
		if err != nil {
			// Check for the sentinel error of `io.EOF`
			if err == io.EOF {
				return
			}
			log.Fatal(err)
		}
		if _, err := os.Stdout.Write(buff[:i]); err != nil {
			log.Fatal(err)
		}
	}
}

Custom Sentinel Errors

You can also create your own sentinel errors.


var errConfigNotFound = errors.New("config not found")

func main() {
	c, err := openConfig("./config.json")
	if err != nil {
		if err == errConfigNotFound {
			fmt.Println("using default config")
			// ... load default config, etc.
			return
		}
		log.Fatal(err)
	}
	fmt.Printf("%+v\n", c)
}

type config struct {
	Backup bool `json:"backup"`
	Debug  bool `json:"debug"`
}

func openConfig(path string) (*config, error) {
	f, err := os.Open(path)
	if err != nil {
		// Return our own sentinel error
		return nil, errConfigNotFound
	}

	c := config{}

	if err := json.NewDecoder(f).Decode(&c); err != nil {
		return nil, err
	}
	return &c, nil
}

Output:


using default config

Errors Are Interfaces

Errors in Go are defined as an interface in the standard library.

type error interface {
 Error() string
}

Anything that defines this method can now satisfy that interface, and as such, be an error.

Defining Your Error

You can create even a simple custom error as long as you satisfy the error interface:


type strError string

func (s strError) Error() string {
	return string(s)
}

func main() {
	err := boom()
	if err != nil {
		log.Fatal(err)
	}
}

func boom() error {
	return strError("something went wrong")
}

Output:


2020/04/18 13:55:40 something went wrong
exit status 1

Complex Custom Error


type ComplexError struct {
	File   string
	Server string
}

func (c ComplexError) Error() string {
	return fmt.Sprintf("%s: %s", c.File, c.Server)
}

func boom() error {
	return ComplexError{
		File:   "main.go",
		Server: "myhost",
	}
}

Using Type Assertion On Errors

Using type assertion, you can get access to the concrete type and therefore access the fields and methods of the concrete type.


func main() {
	err := boom()
	if err != nil {
		log.Println(err)
	}
	if ce, ok := err.(ComplexError); ok {
		log.Println(ce.Server)
	}
}

Comparing Errors: Why `==` Falls Short

Each call to errors.New returns a new, distinct error value. Two errors created from the exact same message are therefore not equal under ==:


var err3 = errors.New("EOF")
var err4 = errors.New("EOF")

fmt.Printf("err3 == err4: %v\n", err3 == err4) // false

The fix isn’t just a different operator: comparing these two values with errors.Is is false too, because neither one is derived from the other. errors.Is does not compare error messages. Instead, compare against the actual sentinel your code returns or wraps. Define it once (for example var ErrNotFound = errors.New("not found")) and check for it with errors.Is, which also walks the chain of wrapped errors and honors an Is method. (We cover errors.Is in detail later in this module.)

Advanced: Because Go constants are comparable by value, you can base an error on a string type and declare sentinel errors as const. Constant errors are value-comparable and can’t be reassigned by another package at runtime. It’s a niche technique, worth knowing but rarely needed.

Constant Errors - Dave Cheney on immutable, value-comparable sentinel errors.

Sentinel Errors Best Practices

Despite a few use cases in the standard library, it is generally considered to be “best practice” to carefully manage sentinel errors.

Traditional approach (less flexible):

  • Sentinel errors become part of your public API
  • Sentinel errors create a dependency between two packages

Modern approach (recommended):

  • Keep error types unexported (lowercase) to maintain API flexibility
  • Provide predicate functions (like IsNotFound(err error) bool) as your public API
  • Use errors.AsType() inside predicate functions to check for unexported types (errors.As() before Go 1.26)
  • Never add Is() method to error types (it becomes part of your API contract forever)
  • Use constant errors for truly immutable values

A great example of this is the os.IsExist method in the standard library. This will allow you better control over how this API is used vs. declaring a sentinel error.

func IsExist(err error) bool

// Modern best practices for sentinel errors and error checking

// ============================================
// PATTERN 1: Exported Error Variables (Traditional)
// ============================================
// Use for well-known error conditions in public APIs

var (
	// ErrNotFound indicates a resource was not found
	ErrNotFound = errors.New("not found")

	// ErrInvalidInput indicates invalid input was provided
	ErrInvalidInput = errors.New("invalid input")

	// ErrTimeout indicates an operation timed out
	ErrTimeout = errors.New("operation timed out")
)

// ============================================
// PATTERN 2: Unexported Error Types (RECOMMENDED)
// ============================================
// Keep error types unexported to maintain API flexibility
// Use predicate functions to check for error conditions

// notFoundError is unexported - implementation detail
type notFoundError struct {
	Resource string
	ID       string
}

func (e *notFoundError) Error() string {
	return fmt.Sprintf("%s not found: %s", e.Resource, e.ID)
}

// IMPORTANT: No Is() method on the error type!
// Adding Is() to the type makes it part of your API contract forever.
// You can't remove it without breaking changes.

// ============================================
// PATTERN 3: Predicate Functions (BEST PRACTICE)
// ============================================
// Predicate functions give you flexibility to change implementation

// IsNotFound checks if an error represents a "not found" condition
// This is the public API - you can change the implementation anytime
func IsNotFound(err error) bool {
	// Check for sentinel error
	if errors.Is(err, ErrNotFound) {
		return true
	}
	// Check for unexported custom type using errors.AsType
	_, ok := errors.AsType[*notFoundError](err)
	return ok
}

// IsTimeout checks if an error represents a timeout condition
func IsTimeout(err error) bool {
	return errors.Is(err, ErrTimeout)
}

// IsTemporary checks if an error is temporary (like network errors)
// This follows the pattern used by net.Error
type temporary interface {
	error
	Temporary() bool
}

func IsTemporary(err error) bool {
	t, ok := errors.AsType[temporary](err)
	return ok && t.Temporary()
}

// ============================================
// PATTERN 4: Wrapping Sentinel Errors
// ============================================

// findUser simulates a database lookup
func findUser(id string) error {
	// Simulate not found - return unexported error type
	if id == "" {
		return &notFoundError{Resource: "user", ID: id}
	}
	return nil
}

// getUserProfile demonstrates proper error wrapping with sentinels
func getUserProfile(userID string) error {
	err := findUser(userID)
	if err != nil {
		// Wrap the error while preserving the sentinel
		return fmt.Errorf("failed to get user profile: %w", err)
	}
	return nil
}

// ============================================
// PATTERN 5: Constant Errors
// ============================================
// For truly immutable error values

type constError string

func (e constError) Error() string {
	return string(e)
}

const (
	ErrInvalidFormat constError = "invalid format"
	ErrOutOfRange    constError = "value out of range"
)

// ============================================
// EXAMPLES
// ============================================

func demonstrateSentinelErrors() {
	fmt.Printf("=== Sentinel Error Patterns ===\n\n")

	// Example 1: Traditional sentinel error
	fmt.Println("1. Traditional Sentinel Error:")
	err1 := ErrNotFound
	if errors.Is(err1, ErrNotFound) {
		fmt.Printf("   ✓ Matched sentinel error: %v\n", err1)
	}

	// Example 2: Wrapped sentinel error
	fmt.Println("\n2. Wrapped Sentinel Error:")
	err2 := fmt.Errorf("operation failed: %w", ErrTimeout)
	if errors.Is(err2, ErrTimeout) {
		fmt.Printf("   ✓ Matched wrapped sentinel: %v\n", err2)
	}

	// Example 3: Unexported custom error type via predicate
	fmt.Println("\n3. Unexported Custom Error Type:")
	err3 := &notFoundError{Resource: "user", ID: "123"}
	if IsNotFound(err3) {
		fmt.Printf("   ✓ Matched via predicate function: %v\n", err3)
	}

	// Example 4: Wrapped custom error
	fmt.Println("\n4. Wrapped Custom Error:")
	err4 := getUserProfile("")
	if IsNotFound(err4) {
		fmt.Printf("   ✓ Matched wrapped custom error: %v\n", err4)
	}

	// Example 5: Standard library errors
	fmt.Println("\n5. Standard Library Errors:")
	err5 := io.EOF
	if errors.Is(err5, io.EOF) {
		fmt.Printf("   ✓ Matched io.EOF: %v\n", err5)
	}

	err6 := fs.ErrNotExist
	if errors.Is(err6, fs.ErrNotExist) {
		fmt.Printf("   ✓ Matched fs.ErrNotExist: %v\n", err6)
	}

	// Example 6: Constant errors
	fmt.Println("\n6. Constant Errors:")
	err7 := ErrInvalidFormat
	if err7 == ErrInvalidFormat {
		fmt.Printf("   ✓ Matched constant error: %v\n", err7)
	}
}

// ============================================
// BEST PRACTICES SUMMARY
// ============================================

func main() {
	demonstrateSentinelErrors()

	fmt.Println("\n=== Best Practices ===")
	fmt.Println("✓ Use predicate functions (IsNotFound) instead of exposing sentinels")
	fmt.Println("✓ Keep error types UNEXPORTED to maintain API flexibility")
	fmt.Println("✓ Always wrap errors with %w to preserve error chains")
	fmt.Println("✓ Use errors.Is() for sentinel error comparison")
	fmt.Println("✓ Use errors.AsType[T]() inside predicate functions for type checking (errors.As() before Go 1.26)")
	fmt.Println("✓ Consider constant errors for truly immutable values")
	fmt.Println("✗ NEVER add Is() method to error types (locks you into the API forever)")
	fmt.Println("✗ NEVER export error types (prevents deprecation and changes)")
	fmt.Println("✗ Avoid direct equality checks (err == ErrNotFound) on wrapped errors")
}

Exercise (15 Min)


package main

import (
	"fmt"
	"log"
)

// #TODO: Declare a struct called `errNotFound`
// - Add an unexported field called `key` of type `string

// #TODO: Implement the error interface for `errNotFound`
// - It should return a string formatted as:
//   "couldn't find value for key: <key>"
//   hint: you can use the fmt.Sprintf function

func main() {
	s := NewStore()

	// check to see if key exists
	_, err := s.Get("Cory")
	// #TODO: Change the following code to check for the specific error of `errNotFound`
	//        - If it is the `errNotFound`, then only set the value for `Cory`
	//        - If it is any other error, log.Fatal instead.
	if err != nil {
		s.Set("Cory", "cory@gopherguides.com")
	}

	v, err := s.Get("Cory")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(v)
}

type Store struct {
	keys map[string]string
}

func NewStore() *Store {
	return &Store{
		keys: make(map[string]string),
	}
}

func (s *Store) Set(k, v string) {
	s.keys[k] = v
}

func (s *Store) Get(k string) (string, error) {
	if v, ok := s.keys[k]; ok {
		return v, nil
	}
	// #TODO: return a errNotFound error instead of this generic error
	return "", fmt.Errorf("couldn't find value for key: %s", k)
}

Solution


package main

import (
	"fmt"
	"log"
)

type errNotFound struct {
	key string
}

func (e errNotFound) Error() string {
	return fmt.Sprintf("couldn't find value for key: %s", e.key)
}

func main() {
	s := NewStore()

	// check to see if key exists
	_, err := s.Get("Cory")
	if err != nil {
		if _, ok := err.(errNotFound); !ok {
			log.Fatal(err)
		}
		s.Set("Cory", "cory@gopherguides.com")
	}

	v, err := s.Get("Cory")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(v)
}

type Store struct {
	keys map[string]string
}

func NewStore() *Store {
	return &Store{
		keys: make(map[string]string),
	}
}

func (s *Store) Set(k, v string) {
	s.keys[k] = v
}

func (s *Store) Get(k string) (string, error) {
	if v, ok := s.keys[k]; ok {
		return v, nil
	}
	return "", errNotFound{key: k}
}

Wrapping Errors

It is very common when writing code to wrap your error with more context. You can wrap them with the %w verb when using the fmt.Errorf function. This will allow for the use of several helpers later which we will cover.

In the following code, we are trying to open a config file. If we can’t find it, we return an error. However, since we want to be as specific as possible, we wrap the error in an additional statement so it makes our code easier to debug at a later time.


func main() {
	c, err := openConfig("./config.json")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%+v\n", c)
}

type config struct {
	Backup bool `json:"backup"`
	Debug  bool `json:"debug"`
}

func openConfig(path string) (*config, error) {
	f, err := os.Open(path)
	if err != nil {
		// Wrap the error in our own message for the future
		return nil, fmt.Errorf("couldn't open config file: %w", err)
	}

	c := config{}

	if err := json.NewDecoder(f).Decode(&c); err != nil {
		return nil, err
	}
	return &c, nil
}

Output:


2020/04/18 13:00:39 couldn't open config file: open ./config.json: no such file or directory

When To Wrap?

When returning errors, you need to decide if you want to wrap them or not. While normally you do, there may be scenarios that you do not.

  • Wrap errors when you want to expose it to callers
  • Don’t wrap when doing so may expose implementation details

You can read more about wrapping errors in this Go blog post.

Standard Library Adoption

The standard library increasingly uses error wrapping to provide better context. For example, as of Go 1.23, net.DNSError wraps errors caused by timeouts or cancellation, enabling patterns like:

_, err := net.LookupHost("example.com")
if errors.Is(err, context.DeadlineExceeded) {
    // Handle timeout
}
if errors.Is(err, context.Canceled) {
    // Handle cancellation
}

This demonstrates that error wrapping with %w isn’t just a best practice—it’s a pattern the Go team continues to adopt throughout the standard library.

Unwrapping Errors

Go 1.13 introduced several ways to unwrap an error to check for a specific error, and Go 1.26 added a generic version of errors.As.

Errors.Is

You can use errors.Is to check for a sentinel error.


func main() {
	c, err := openConfig("./config.json")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%+v\n", c)
}

type config struct {
	Backup bool `json:"backup"`
	Debug  bool `json:"debug"`
}

func openConfig(path string) (*config, error) {
	var f io.Reader
	var err error
	f, err = os.Open(path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			// use default config
			f = bytes.NewBufferString(`{"backup":false,"debug":true}`)
		} else {
			return nil, fmt.Errorf("something went terribly wrong...: %w", err)
		}
	}

	c := config{}

	if err := json.NewDecoder(f).Decode(&c); err != nil {
		return nil, err
	}
	return &c, nil
}

Output:


&{Backup:false Debug:true}

Errors.AsType

To check for a specific error type, use the generic errors.AsType function, added in Go 1.26. You pass the type you are looking for as a type argument, and it returns the matched error along with a bool:


func main() {
	err := boom()
	if err != nil {
		log.Println("error: ", err)
	}
	ce, ok := errors.AsType[ComplexError](err)
	if ok {
		log.Println("server:", ce.Server)
	}
}

Notice that when ok is true, ce is already the concrete ComplexError, so we can access its fields directly.

Before Go 1.26, you used the two step errors.As function instead. You declare a variable of the target type first, then pass a pointer to it:

var ce ComplexError
if errors.As(err, &ce) {
	log.Println("server:", ce.Server)
}

This form still works, and you’ll run across it in nearly every existing Go codebase, so you need to be able to read both.

Stacked Errors

Even if there are several errors stacked in a set of wrapped errors:


func boom() error {
	ce := ComplexError{
		File:   "main.go",
		Server: "myhost",
	}
	err := fmt.Errorf("original error: %w", ce)
	err = fmt.Errorf("wrap again: %w", err)
	return err
}

errors.AsType will traverse the stack until it finds the first instance of the error that matches the type you asked for (errors.As walks the stack the same way):


func main() {
	err := boom()
	if err != nil {
		log.Println("error: ", err)
	}
	ce, ok := errors.AsType[ComplexError](err)
	if ok {
		log.Println("server:", ce.Server)
	}
}

Output:


2026/07/10 15:09:07 error:  wrap again: original error: main.go: myhost
2026/07/10 15:09:07 server: myhost

Declaring Custom Unwrap Behavior

You can also implement Unwrap in your own errors as well.


type ComplexError struct {
	File   string
	Server string
	err    error
}

func (c ComplexError) Error() string {
	return fmt.Sprintf("%s: %s - %s", c.File, c.Server, c.err)
}

func (c ComplexError) Unwrap() error {
	return c.err
}

func boom() error {
	return ComplexError{
		File:   "main.go",
		Server: "myhost",
		err:    io.EOF,
	}
}

func main() {
	err := boom()
	if e := errors.Unwrap(err); err != nil {
		fmt.Printf("original error was: %s", e)
	}
}

Output:


original error was: EOF

Errors.Unwrap

Any time you wrap an error by using the %w verb in fmt.Errorf you are able to Unwrap it later.

You can unwrap until you get to nil which means you are likely at the beginning of where the errors started.


type ComplexError struct {
	File   string
	Server string
}

func (c ComplexError) Error() string {
	return fmt.Sprintf("%s: %s", c.File, c.Server)
}

func boom() error {
	ce := ComplexError{
		File:   "main.go",
		Server: "myhost",
	}
	err := fmt.Errorf("original error: %w", ce)
	err = fmt.Errorf("wrap again: %w", err)
	return err
}

func main() {
	err := boom()

	// Unwrap and print all errors
	for {
		if err == nil {
			return
		}
		fmt.Println(err)
		err = errors.Unwrap(err)
	}
}

Output:


wrap again: original error: main.go: myhost
original error: main.go: myhost
main.go: myhost

Unwrapping Multiple Errors

Because errors.Is and errors.AsType will walk the stack of errors, it is safe to use either of those functions on the same error. The older errors.As walks the stack the same way:


var errUnknown = errors.New("unknown error")

type ComplexError struct {
	File   string
	Server string
	err    error
}

func (c ComplexError) Error() string {
	return fmt.Sprintf("%s: %s", c.File, c.Server)
}

func (c ComplexError) Unwrap() error {
	return c.err
}

func main() {
	err := boom()

	if errors.Is(err, errUnknown) {
		fmt.Println("found simple error")
	}

	if _, ok := errors.AsType[ComplexError](err); ok {
		fmt.Println("found complex error")
	}
}

func boom() error {
	ce := ComplexError{
		File:   "README.md",
		Server: "localhost:9090",
		err:    errUnknown,
	}
	return fmt.Errorf("wrapping a complex error: %w", ce)
}

Output:


found simple error
found complex error

Advanced Error Chain Traversal

For complex applications, you may need to traverse and inspect entire error chains. This is useful for debugging, logging, and understanding the full context of an error.


// Advanced error chain traversal patterns demonstrate
// how to inspect and navigate complex error chains

type ValidationError struct {
	Field   string
	Message string
	err     error
}

func (v *ValidationError) Error() string {
	return fmt.Sprintf("validation error on field %s: %s", v.Field, v.Message)
}

func (v *ValidationError) Unwrap() error {
	return v.err
}

type DatabaseError struct {
	Query string
	err   error
}

func (d *DatabaseError) Error() string {
	return fmt.Sprintf("database error executing query: %s", d.Query)
}

func (d *DatabaseError) Unwrap() error {
	return d.err
}

// collectErrorChain traverses the entire error chain and returns all errors
func collectErrorChain(err error) []error {
	var chain []error
	for err != nil {
		chain = append(chain, err)
		err = errors.Unwrap(err)
	}
	return chain
}

// findErrorInChain searches for a specific error type in the chain
func findErrorInChain[T error](err error) (T, bool) {
	for err != nil {
		if target, ok := errors.AsType[T](err); ok {
			return target, true
		}
		err = errors.Unwrap(err)
	}
	var zero T
	return zero, false
}

// printErrorChain displays the entire error chain with depth indicators
func printErrorChain(err error) {
	chain := collectErrorChain(err)
	fmt.Println("Error Chain:")
	for i, e := range chain {
		indent := ""
		for j := 0; j < i; j++ {
			indent += "  "
		}
		fmt.Printf("%s[%d] %v\n", indent, i, e)
	}
}

func simulateError() error {
	// Start with a base error
	baseErr := io.ErrUnexpectedEOF

	// Wrap in a database error
	dbErr := &DatabaseError{
		Query: "SELECT * FROM users WHERE id = ?",
		err:   baseErr,
	}

	// Wrap in a validation error
	valErr := &ValidationError{
		Field:   "user_id",
		Message: "failed to fetch user",
		err:     dbErr,
	}

	// Wrap with additional context
	return fmt.Errorf("operation failed: %w", valErr)
}

func main() {
	err := simulateError()

	// Print the entire error chain
	printErrorChain(err)

	fmt.Println("\n--- Error Inspection ---")

	// Check for specific error in the chain
	if errors.Is(err, io.ErrUnexpectedEOF) {
		fmt.Println("✓ Chain contains io.ErrUnexpectedEOF")
	}

	// Extract specific error type from chain
	if dbErr, ok := errors.AsType[*DatabaseError](err); ok {
		fmt.Printf("✓ Found DatabaseError with query: %s\n", dbErr.Query)
	}

	if valErr, ok := errors.AsType[*ValidationError](err); ok {
		fmt.Printf("✓ Found ValidationError on field: %s\n", valErr.Field)
	}

	fmt.Println("\n--- Manual Chain Traversal ---")
	// Manually walk the chain
	currentErr := err
	depth := 0
	for currentErr != nil {
		fmt.Printf("Level %d: %T\n", depth, currentErr)
		currentErr = errors.Unwrap(currentErr)
		depth++
	}
}

Output:


Error Chain:
[0] operation failed: validation error on field user_id: failed to fetch user
  [1] validation error on field user_id: failed to fetch user
    [2] database error executing query: SELECT * FROM users WHERE id = ?
      [3] unexpected EOF

--- Error Inspection ---
 Chain contains io.ErrUnexpectedEOF
 Found DatabaseError with query: SELECT * FROM users WHERE id = ?
 Found ValidationError on field: user_id

--- Manual Chain Traversal ---
Level 0: *fmt.wrapError
Level 1: *main.ValidationError
Level 2: *main.DatabaseError
Level 3: *errors.errorString

Custom Unmarshal Error Handling

When working with JSON or other data formats, it’s important to provide clear error messages about what went wrong during unmarshaling. Custom error types can help provide detailed context about validation failures and type mismatches.


// Custom error handling for JSON unmarshaling with detailed error information

// UnmarshalError provides detailed information about JSON unmarshaling failures
type UnmarshalError struct {
	Data      string
	FieldPath string
	Reason    string
	err       error
}

func (u *UnmarshalError) Error() string {
	return fmt.Sprintf("unmarshal error at %s: %s", u.FieldPath, u.Reason)
}

func (u *UnmarshalError) Unwrap() error {
	return u.err
}

// Config represents application configuration
type Config struct {
	Port     int      `json:"port"`
	Host     string   `json:"host"`
	Features Features `json:"features"`
}

type Features struct {
	Debug   bool `json:"debug"`
	Logging bool `json:"logging"`
}

// UnmarshalJSON implements custom unmarshaling with detailed error handling
func (c *Config) UnmarshalJSON(data []byte) error {
	// Use an alias to avoid infinite recursion
	type Alias Config
	aux := &struct {
		*Alias
	}{
		Alias: (*Alias)(c),
	}

	if err := json.Unmarshal(data, &aux); err != nil {
		// Extract field information from JSON error
		if syntaxErr, ok := errors.AsType[*json.SyntaxError](err); ok {
			return &UnmarshalError{
				Data:      string(data),
				FieldPath: fmt.Sprintf("offset %d", syntaxErr.Offset),
				Reason:    syntaxErr.Error(),
				err:       err,
			}
		}
		if unmarshalErr, ok := errors.AsType[*json.UnmarshalTypeError](err); ok {
			return &UnmarshalError{
				Data:      string(data),
				FieldPath: unmarshalErr.Field,
				Reason:    fmt.Sprintf("expected %s but got %s", unmarshalErr.Type, unmarshalErr.Value),
				err:       err,
			}
		}
		return &UnmarshalError{
			Data:      string(data),
			FieldPath: "unknown",
			Reason:    err.Error(),
			err:       err,
		}
	}

	// Validate configuration
	if c.Port < 1 || c.Port > 65535 {
		return &UnmarshalError{
			Data:      string(data),
			FieldPath: "port",
			Reason:    fmt.Sprintf("port must be between 1 and 65535, got %d", c.Port),
			err:       errors.New("invalid port number"),
		}
	}

	if c.Host == "" {
		return &UnmarshalError{
			Data:      string(data),
			FieldPath: "host",
			Reason:    "host cannot be empty",
			err:       errors.New("missing required field"),
		}
	}

	return nil
}

// parseConfig attempts to parse JSON configuration with detailed error reporting
func parseConfig(jsonData string) (*Config, error) {
	var config Config
	err := json.Unmarshal([]byte(jsonData), &config)
	if err != nil {
		return nil, fmt.Errorf("failed to parse configuration: %w", err)
	}
	return &config, nil
}

// handleUnmarshalError demonstrates how to handle unmarshal errors
func handleUnmarshalError(err error) {
	unmarshalErr, ok := errors.AsType[*UnmarshalError](err)
	if !ok {
		fmt.Printf("Unexpected error: %v\n", err)
		return
	}

	fmt.Printf("Field: %s\n", unmarshalErr.FieldPath)
	fmt.Printf("Reason: %s\n", unmarshalErr.Reason)
	fmt.Printf("Data: %s\n", strings.TrimSpace(unmarshalErr.Data))

	// Check for underlying JSON errors
	if typeErr, ok := errors.AsType[*json.UnmarshalTypeError](err); ok {
		fmt.Printf("Type mismatch: field=%s, expected=%s, got=%s\n",
			typeErr.Field, typeErr.Type, typeErr.Value)
	}
}

func main() {
	// Example 1: Type mismatch error
	fmt.Println("=== Example 1: Type Mismatch ===")
	invalidTypeJSON := `{"port": "not-a-number", "host": "localhost", "features": {"debug": true, "logging": false}}`
	config, err := parseConfig(invalidTypeJSON)
	if err != nil {
		handleUnmarshalError(err)
	}

	fmt.Println("\n=== Example 2: Validation Error ===")
	invalidPortJSON := `{"port": 99999, "host": "localhost", "features": {"debug": true, "logging": false}}`
	config, err = parseConfig(invalidPortJSON)
	if err != nil {
		handleUnmarshalError(err)
	}

	fmt.Println("\n=== Example 3: Missing Required Field ===")
	missingFieldJSON := `{"port": 8080, "host": "", "features": {"debug": true, "logging": false}}`
	config, err = parseConfig(missingFieldJSON)
	if err != nil {
		handleUnmarshalError(err)
	}

	fmt.Println("\n=== Example 4: Valid Configuration ===")
	validJSON := `{"port": 8080, "host": "localhost", "features": {"debug": true, "logging": false}}`
	config, err = parseConfig(validJSON)
	if err != nil {
		handleUnmarshalError(err)
	} else {
		fmt.Printf("✓ Configuration loaded successfully: %+v\n", config)
	}
}

Output:


=== Example 1: Type Mismatch ===
Field: Alias.port
Reason: expected int but got string
Data: {"port": "not-a-number", "host": "localhost", "features": {"debug": true, "logging": false}}
Type mismatch: field=Alias.port, expected=int, got=string

=== Example 2: Validation Error ===
Field: port
Reason: port must be between 1 and 65535, got 99999
Data: {"port": 99999, "host": "localhost", "features": {"debug": true, "logging": false}}

=== Example 3: Missing Required Field ===
Field: host
Reason: host cannot be empty
Data: {"port": 8080, "host": "", "features": {"debug": true, "logging": false}}

=== Example 4: Valid Configuration ===
 Configuration loaded successfully: &{Port:8080 Host:localhost Features:{Debug:true Logging:false}}

Nil Typed Errors

Be wary of comparing nil types. Even though you return nil from a function, if it has a type, it will not be nil in the same way that an error is nil.

The reason for this is that Go stores the information of an interface as both the type and the value. An interface value is only truly nil if both the type and the value are not set.

In this case, the interface is {Type: *errFound, Value: nil} and not {Type: nil, Value: nil}


type errFound string

func (e *errFound) Error() string {
	return "found"
}

func main() {
	err := exists("README.md")
	if err != nil {
		fmt.Println("error was NOT nil")
		if _, ok := errors.AsType[*errFound](err); ok {
			fmt.Println("this was a errFound...")
		}
	}
}

func exists(path string) error {
	if path == "README.md" {
		// return a `typed` nil value...
		var e *errFound
		if e == nil {
			fmt.Println("yup, it's nil here..")
		}
		return e
	}
	return nil
}

Output:


yup, it's nil here..
error was NOT nil
this was a errFound...

Spot The Bug

The following code has a subtle yet common bug:


func main() {
	// Check for the sentinel error of `io.EOF`
	err := cat("./README.md")
	if !errors.Is(err, io.EOF) {
		log.Fatalf("something went unexpected: %s", err)
	}
}

func cat(path string) error {
	f, err := os.Open(path)
	if err != nil {
		return err
	}

	buff := make([]byte, 10)

	for {
		i, err := f.Read(buff)
		if err != nil {
			return fmt.Errorf("got a read error: %s", err)
		}
		if _, err := os.Stdout.Write(buff[:i]); err != nil {
			return fmt.Errorf("write error: %s", err)
		}
	}
}

Output:


# Title

Description of project

## Getting Started

Quick start guides here...

### Prerequisites

Here is what you need to know before you start...
2020/04/18 15:13:56 something went unexpected: got a read error: EOF
exit status 1

Bug Solution

Make sure to always wrap errors with the %w verb, and not the %s verb.


func main() {
	// Check for the sentinel error of `io.EOF`
	err := cat("./README.md")
	if !errors.Is(err, io.EOF) {
		log.Fatalf("something went unexpected: %s", err)
	}
}

func cat(path string) error {
	f, err := os.Open(path)
	if err != nil {
		return err
	}

	buff := make([]byte, 10)

	for {
		i, err := f.Read(buff)
		if err != nil {
			return fmt.Errorf("got a read error: %w", err)
		}
		if _, err := os.Stdout.Write(buff[:i]); err != nil {
			return fmt.Errorf("write error: %w", err)
		}
	}
}

Output:


# Title

Description of project

## Getting Started

Quick start guides here...

### Prerequisites

Here is what you need to know before you start...

Panics

Occasionally in your code you will do something that the Go runtime does not like.

a := []string{}
a[42] = "Bring a towel"

This code will panic and your application will crash.

panic: runtime error: index out of range

goroutine 1 [running]:
main.main()
 panic.go:5 +0x11

Recover From A Panic

With a combination of the defer keyword and the the recover function we can recover from panics in our applications and gracefully handle them.


package main

import "fmt"

func main() {
	defer func() {
		if err := recover(); err != nil {
			fmt.Println("oh no, a panic occurred:", err)
		}
	}()
	a := []string{}
	a[42] = "Bring a towel"
}

Using Recover

One of the most common reasons to use recover is when you allow the developer to provide a function as an argument to your function or method. Because it’s possible that the developer wrote bad code that could panic, it’s best to try to recover from that situation and report an error to the end user that they encountered a panic.

Looking at the following code, without using the recover pattern, we allow the vulnerability of the developer code panicing and thus shutting down our program:


// You can edit this code!
// Click here and start typing.
package main

import (
	"fmt"
	"log"
)

type matcher func(rune) bool

func main() {
	m := func(r rune) bool {
		defer panic("hahaha")
		if r == 'g' || r == 'o' {
			return true
		}
		// bad code....
		return false
	}
	s, err := sanitize(m, "go is awesome")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(s)
}

func sanitize(m matcher, s string) (string, error) {
	var val string

	for _, c := range s {
		if m(c) {
			val = val + "*"
			continue
		}
		val = val + string(c)
	}
	return val, nil
}

The Fix With Recover

We can update the previous code with a defer/recover so that if the provided function panics, we can gracefully recover and send an error back that describes the error. This is very similar to what the net/http package does for handlers.


// You can edit this code!
// Click here and start typing.
package main

import (
	"fmt"
	"log"
)

type matcher func(rune) bool

func main() {
	m := func(r rune) bool {
		defer panic("hahaha")
		if r == 'g' || r == 'o' {
			return true
		}
		return false
	}
	s, err := sanitize(m, "go is awesome")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(s)
}

func sanitize(m matcher, s string) (val string, err error) {
	defer func() {
		if e := recover(); e != nil {
			err = fmt.Errorf("invalid matcher. panic occurred: %v", e)
		}
	}()

	for _, c := range s {
		if m(c) {
			val = val + "*"
			continue
		}
		val = val + string(c)
	}
	return
}

Avoid Panicking

In general, it is good practice not to panic. It is better to return an error and let others figure out how to handle it in their applications.

Alternate Errors Package

Previous to Go 1.13, there wasn’t a way to wrap errors. As such, a third-party package become very popular in the Go community. You’ll almost certainly run across this package at some point.

github.com/pkg/errors

While the concepts in this package are close to the standard library, they don’t share the same API. As such, if you are trying to unwrap errors from third party packages as well as the standard library, you’ll have to check for both interfaces. You can do this manually, or you can use the errx package to solve this problem if needed.

Stack Traces

There may be times that you want to output a stack trace to a log, file, etc. You can do this by using the runtime/debug package.


package main

import "runtime/debug"

func main() {
	First("Hello")
}

func First(s string) {
	Second(s)
}

func Second(s string) {
	Third(s)
}

func Third(s string) {
	debug.PrintStack()
}

Output:


goroutine 1 [running]:
runtime/debug.Stack(0xc000040778, 0xc000108f78, 0x1004685)
        /usr/local/Cellar/go/1.16.6/libexec/src/runtime/debug/stack.go:24 +0x9f
runtime/debug.PrintStack()
        /usr/local/Cellar/go/1.16.6/libexec/src/runtime/debug/stack.go:16 +0x25
main.Third(...)
        /Users/corylanou/projects/gopherguides/learn/_training/fundamentals/errors/src/examples/stack/main.go:18
main.Second(...)
        /Users/corylanou/projects/gopherguides/learn/_training/fundamentals/errors/src/examples/stack/main.go:14
main.First(...)
        /Users/corylanou/projects/gopherguides/learn/_training/fundamentals/errors/src/examples/stack/main.go:10
main.main()
        /Users/corylanou/projects/gopherguides/learn/_training/fundamentals/errors/src/examples/stack/main.go:6 +0x25

Exercise (15 Min)


package main

import (
	"errors"
	"fmt"
	"log"
)

var errInvalidKey = errors.New("invalid key")

type errNotFound struct {
	key string
}

func (e errNotFound) Error() string {
	return fmt.Sprintf("unable to find value for %q", e.key)
}

func lookup(key string) (string, error) {
	data := map[string]string{"foo": "bar", "mascot": "gopher", "go": "awesome"}

	if key == "" {
		return "", errInvalidKey
	}
	if v, ok := data[key]; ok {
		return v, nil
	}
	return "", errNotFound{key: key}
}

// TODO: Convert the following error check to use errors.Is
func noKey() {
	var key string
	v, err := lookup(key)
	if err != nil {
		if err != errInvalidKey {
			log.Fatal(err)
		}
		fmt.Println("please specify a valid key")
		return
	}
	fmt.Printf("value for %q is %q", key, v)
}

// TODO: Convert the following error check to use errors.AsType (Go 1.26+)
func notFound() {
	key := "baz"
	v, err := lookup(key)
	if err != nil {
		if _, ok := err.(errNotFound); !ok {
			log.Fatal(err)
		}
		fmt.Printf("error: %s\n", err)
		return
	}
	fmt.Printf("value for %q is %q", key, v)
}

func found() {
	key := "go"
	v, err := lookup(key)
	if err != nil {
		if _, ok := err.(errNotFound); !ok {
			log.Fatal(err)
		}
		fmt.Printf("error: %s\n", err)
		return
	}
	fmt.Printf("value for %q is %q", key, v)
}

// TODO: Update the following function to print a stack trace when i == 10
func nested(i int) {
	if i == 10 {
		return
	}
	fmt.Println("nested call", i)
	nested(i + 1)
}

func main() {
	noKey()
	notFound()
	found()
	nested(1)
}

Solution


package main

import (
	"errors"
	"fmt"
	"log"
	"runtime/debug"
)

var errInvalidKey = errors.New("invalid key")

type errNotFound struct {
	key string
}

func (e errNotFound) Error() string {
	return fmt.Sprintf("unable to find value for %q", e.key)
}

func lookup(key string) (string, error) {
	data := map[string]string{"foo": "bar", "mascot": "gopher", "go": "awesome"}

	if key == "" {
		return "", errInvalidKey
	}
	if v, ok := data[key]; ok {
		return v, nil
	}
	return "", errNotFound{key: key}
}

func noKey() {
	var key string
	v, err := lookup(key)
	if err != nil {
		if !errors.Is(err, errInvalidKey) {
			log.Fatal(err)
		}
		fmt.Println("please specify a valid key")
		return
	}
	fmt.Printf("value for %q is %q", key, v)
}

func notFound() {
	key := "baz"
	v, err := lookup(key)
	if err != nil {
		if _, ok := errors.AsType[errNotFound](err); !ok {
			log.Fatal(err)
		}
		fmt.Printf("error: %s\n", err)
		return
	}
	fmt.Printf("value for %q is %q", key, v)
}

func found() {
	key := "go"
	v, err := lookup(key)
	if err != nil {
		if _, ok := err.(errNotFound); !ok {
			log.Fatal(err)
		}
		fmt.Printf("error: %s\n", err)
		return
	}
	fmt.Printf("value for %q is %q", key, v)
}

// TODO: Update the following function to print a stack trace when i == 10
func nested(i int) {
	if i == 10 {
		debug.PrintStack()
		return
	}
	fmt.Println("nested call", i)
	nested(i + 1)
}

func main() {
	noKey()
	notFound()
	found()
	nested(1)
}