Free Ebook cover Go (Golang) Fundamentals: Simple, Fast Programs for Beginners

Go (Golang) Fundamentals: Simple, Fast Programs for Beginners

New course

12 pages

Error Handling in Go: Returning Errors, Wrapping, and Clean Control Flow

Capítulo 9

Estimated reading time: 9 minutes

+ Exercise

Error as a Value: Return error, Check Immediately, Keep the Happy Path Clear

In Go, errors are ordinary values. Functions that can fail typically return an error as their last return value. Callers are responsible for checking it immediately and deciding what to do next. This style keeps control flow explicit and avoids hidden exceptions.

Idiomatic pattern: guard clauses

A common goal is to keep the “happy path” (the successful flow) unindented and easy to read. You do this by checking errors early and returning as soon as something goes wrong.

package main

import (
	"fmt"
	"os"
)

func readConfig(path string) ([]byte, error) {
	b, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	return b, nil
}

func main() {
	b, err := readConfig("config.json")
	if err != nil {
		fmt.Println("failed:", err)
		return
	}
	fmt.Println("bytes:", len(b))
}

Step-by-step: how to structure a function that can fail

  • Do one operation.
  • Immediately check err.
  • If it failed, return a useful error (often wrapping the original).
  • Continue with the next operation only after success.

This approach scales well in production code because it makes failure points obvious and keeps stack traces unnecessary: the error value itself carries context.

Creating Errors and Wrapping with Context

When you detect an error condition, you create an error value. When you receive an error from a lower-level function, you often want to add context (what you were trying to do) while preserving the original error for inspection.

errors.New: simple static errors

import "errors"

var ErrEmptyInput = errors.New("empty input")

errors.New is best when the message is fixed and you do not need formatting.

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

fmt.Errorf: formatted errors and wrapping

fmt.Errorf can format messages and can wrap another error using %w. Wrapping is important because it preserves the original error so callers can still detect it with errors.Is or extract types with errors.As.

import (
	"fmt"
)

func loadUser(id string) error {
	err := doDBLookup(id)
	if err != nil {
		return fmt.Errorf("load user %q: %w", id, err)
	}
	return nil
}

When to wrap vs when to replace

  • Wrap (%w) when the underlying error is meaningful to callers (e.g., they may want to detect a category like “not found”).
  • Replace (new error without wrapping) when you intentionally want to hide internal details or the underlying error is not actionable.

Common mistake: using %v instead of %w

If you use %v, the message may look fine, but the original error is not wrapped, so errors.Is/errors.As will not work through it.

// Not wrappable:
return fmt.Errorf("load user %q: %v", id, err)

// Wrappable:
return fmt.Errorf("load user %q: %w", id, err)

Sentinel Errors vs Custom Error Types

There are two common strategies for communicating error meaning: sentinel errors (a shared package-level variable) and custom error types (a struct that implements Error() string and may carry fields).

Sentinel errors: simple categories

A sentinel error is a named variable that represents a specific condition. Callers can compare using errors.Is (preferred) rather than ==, especially when wrapping is involved.

package parse

import "errors"

var ErrInvalidFormat = errors.New("invalid format")
var ErrOutOfRange = errors.New("out of range")

Use sentinel errors when:

  • The error condition is simple and does not need extra data.
  • You want callers to branch on a small set of well-known categories.
  • You can keep the set stable (changing it is a breaking API change).

Custom error types: structured details

A custom error type is useful when callers need more information than a message: which field failed, which value was invalid, what limit was exceeded, etc.

package parse

import "fmt"

type ValidationError struct {
	Field string
	Value string
	Msg   string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("%s=%q: %s", e.Field, e.Value, e.Msg)
}

Use custom error types when:

  • You need to attach structured data to the error.
  • Callers should be able to extract that data with errors.As.
  • You want to keep error messages user-friendly while still enabling programmatic handling.

Combining both approaches

You can combine a custom type with a sentinel “category” by wrapping a sentinel or by including a code field. A common approach is to wrap a sentinel error to preserve a stable category while still adding context.

package parse

import (
	"errors"
	"fmt"
)

var ErrInvalidInput = errors.New("invalid input")

type ValidationError struct {
	Field string
	Value string
	Msg   string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("%s=%q: %s", e.Field, e.Value, e.Msg)
}

func (e *ValidationError) Unwrap() error {
	return ErrInvalidInput
}

With Unwrap, errors.Is(err, ErrInvalidInput) can succeed even when the concrete error is *ValidationError.

Inspecting Errors with errors.Is and errors.As

Go’s standard library provides tools to work with wrapped errors. These functions walk the chain created by wrapping (%w) or by types implementing Unwrap() error.

errors.Is: “does this error represent X?”

Use errors.Is to check whether an error is (or wraps) a specific sentinel error.

import (
	"errors"
	"fmt"
	"os"
)

func openFile(path string) error {
	_, err := os.Open(path)
	if err != nil {
		return fmt.Errorf("open %s: %w", path, err)
	}
	return nil
}

func handle(path string) {
	err := openFile(path)
	if err == nil {
		return
	}
	if errors.Is(err, os.ErrNotExist) {
		fmt.Println("missing file")
		return
	}
	fmt.Println("other error:", err)
}

errors.As: “can I treat this as type T?”

Use errors.As when you want to extract a specific error type from the chain to access its fields or methods.

import (
	"errors"
	"fmt"
	"strconv"
)

type ValidationError struct {
	Field string
	Value string
	Msg   string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("%s=%q: %s", e.Field, e.Value, e.Msg)
}

func parseAge(s string) (int, error) {
	age, err := strconv.Atoi(s)
	if err != nil {
		return 0, &ValidationError{Field: "age", Value: s, Msg: "must be an integer"}
	}
	if age < 0 || age > 130 {
		return 0, &ValidationError{Field: "age", Value: s, Msg: "must be between 0 and 130"}
	}
	return age, nil
}

func handleAge(s string) {
	_, err := parseAge(s)
	if err == nil {
		return
	}
	var ve *ValidationError
	if errors.As(err, &ve) {
		fmt.Println("bad field:", ve.Field, "value:", ve.Value, "reason:", ve.Msg)
		return
	}
	fmt.Println("unexpected:", err)
}

Practical guidance

  • Prefer errors.Is for stable categories (sentinel errors).
  • Prefer errors.As when you need structured details (custom types).
  • Wrap errors at boundaries (I/O, parsing, external calls) to add context like operation and identifiers.
  • Avoid over-wrapping at every line; add context where it helps debugging and logs.

defer for Cleanup: Close Resources Even on Error

Many operations allocate resources that must be released: files, network connections, locks, temporary directories. In Go, defer is the idiomatic way to ensure cleanup happens even if you return early due to an error.

Pattern: acquire, check, defer cleanup immediately

import (
	"fmt"
	"os"
)

func countBytes(path string) (int, error) {
	f, err := os.Open(path)
	if err != nil {
		return 0, fmt.Errorf("open %s: %w", path, err)
	}
	defer f.Close()

	info, err := f.Stat()
	if err != nil {
		return 0, fmt.Errorf("stat %s: %w", path, err)
	}
	return int(info.Size()), nil
}

Handling cleanup errors (when it matters)

Some cleanup operations can fail (for example, flushing a buffered writer). If the cleanup error is important, capture it in a deferred function and combine it with the main error.

import (
	"bufio"
	"fmt"
	"os"
)

func writeLine(path, line string) (err error) {
	f, err := os.Create(path)
	if err != nil {
		return fmt.Errorf("create %s: %w", path, err)
	}
	defer func() {
		cerr := f.Close()
		if err == nil && cerr != nil {
			err = fmt.Errorf("close %s: %w", path, cerr)
		}
	}()

	w := bufio.NewWriter(f)
	defer func() {
		ferr := w.Flush()
		if err == nil && ferr != nil {
			err = fmt.Errorf("flush %s: %w", path, ferr)
		}
	}()

	if _, err := w.WriteString(line + "\n"); err != nil {
		return fmt.Errorf("write %s: %w", path, err)
	}
	return nil
}

This pattern uses a named return value (err error) so the deferred functions can update it if the main body succeeded but cleanup failed.

Exercise 1: Parse and Validate Input with Well-Wrapped Errors

Goal: build a small parsing function that returns actionable errors with context. You will practice: early returns, wrapping with %w, and using a custom error type for validation details.

Task

Write ParsePort(s string) (int, error) that:

  • Trims spaces.
  • Rejects empty input.
  • Parses an integer.
  • Validates range 1..65535.
  • Wraps underlying parse errors with context.
  • Returns a *ValidationError for validation failures.

Starter types and sentinel

package main

import (
	"errors"
	"fmt"
	"strconv"
	"strings"
)

var ErrInvalidInput = errors.New("invalid input")

type ValidationError struct {
	Field string
	Value string
	Msg   string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("%s=%q: %s", e.Field, e.Value, e.Msg)
}

func (e *ValidationError) Unwrap() error {
	return ErrInvalidInput
}

Implement step-by-step

  • Normalize: s = strings.TrimSpace(s).
  • If empty: return &ValidationError{Field: "port", Value: s, Msg: "must not be empty"}.
  • Parse: n, err := strconv.Atoi(s); if error, wrap it: fmt.Errorf("parse port %q: %w", s, err).
  • Validate range; on failure return &ValidationError{Field: "port", Value: s, Msg: "must be between 1 and 65535"}.

Reference implementation

func ParsePort(s string) (int, error) {
	s = strings.TrimSpace(s)
	if s == "" {
		return 0, &ValidationError{Field: "port", Value: s, Msg: "must not be empty"}
	}

	n, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("parse port %q: %w", s, err)
	}
	if n < 1 || n > 65535 {
		return 0, &ValidationError{Field: "port", Value: s, Msg: "must be between 1 and 65535"}
	}
	return n, nil
}

Try it

func main() {
	for _, s := range []string{"", " 8080 ", "99999", "abc"} {
		p, err := ParsePort(s)
		fmt.Printf("in=%q port=%d err=%v\n", s, p, err)
	}
}

Exercise 2: Distinguish Error Categories with errors.Is and errors.As

Goal: write a function that classifies errors into categories for control flow (e.g., retry, user message, internal alert). You will practice: sentinel detection with errors.Is and extracting details with errors.As.

Task

Implement Classify(err error) string that returns:

  • "ok" if err == nil
  • "invalid_input" if errors.Is(err, ErrInvalidInput)
  • "not_found" if errors.Is(err, os.ErrNotExist)
  • "validation(field=...)" if errors.As finds a *ValidationError (include the field name)
  • "unknown" otherwise

Note: the order matters. Decide whether you want the more specific type-based classification (ValidationError) to win over the broader sentinel (ErrInvalidInput), or vice versa, and implement accordingly.

Reference implementation (type wins over sentinel)

import (
	"errors"
	"os"
)

func Classify(err error) string {
	if err == nil {
		return "ok"
	}

	var ve *ValidationError
	if errors.As(err, &ve) {
		return "validation(field=" + ve.Field + ")"
	}
	if errors.Is(err, ErrInvalidInput) {
		return "invalid_input"
	}
	if errors.Is(err, os.ErrNotExist) {
		return "not_found"
	}
	return "unknown"
}

Test cases to run

func main() {
	err1 := &ValidationError{Field: "port", Value: "", Msg: "must not be empty"}
	err2 := fmt.Errorf("load config: %w", os.ErrNotExist)
	err3 := fmt.Errorf("top: %w", ErrInvalidInput)

	for _, err := range []error{nil, err1, err2, err3} {
		fmt.Printf("err=%v class=%s\n", err, Classify(err))
	}
}

Now answer the exercise about the content:

In Go, why should you wrap an underlying error using fmt.Errorf with %w instead of %v when adding context?

You are right! Congratulations, now go to the next page

You missed! Try again.

Using %w wraps the original error, creating an error chain that errors.Is and errors.As can traverse. With %v, the message may include the error, but it is not wrapped, so inspection through the chain won’t work.

Next chapter

Testing and Tooling in Go: go test, Benchmarks, and Code Quality Checks

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.