Free Handbook · Every example compiled & verified

Error Handling

Handle failure the Go way: errors as values, wrapping with %w, errors.Is and errors.As, custom error types, and when panic and recover are the right tool.

0 / 134 lessons🔥 0 day streak
ShareXLinkedIn

Module 07 · what you'll be able to do

  • Return and check errors with if err != nil, and create them with errors.New and fmt.Errorf
  • Wrap errors with %w to add context, and inspect the chain with errors.Is, errors.As and errors.Unwrap
  • Define sentinel errors and custom error types with fields and an Unwrap method, and combine several with errors.Join
  • Decide between returning an error and panicking, and use recover only at a boundary
  • Build a small parsing pipeline that reports every bad line with its line number
01

Errors are values

Go has no exceptions and no try/catch. A function that can fail returns an error as its last result, and the caller checks it right away with if err != nil. An error is an ordinary value: you can store it, return it, compare it, put it in a slice or print it.

error is a built-in interface with one method, Error() string (Module 06 showed how interfaces work). The two quickest ways to make one are errors.New("message") for a fixed message and fmt.Errorf("format", args...) when the message needs values in it. nil means "no error".

gomain.go
package main

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

func parseAge(s string) (int, error) {
	n, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("age %q is not a number", s)
	}
	if n < 0 || n > 150 {
		return 0, errors.New("age out of range")
	}
	return n, nil
}

func main() {
	for _, in := range []string{"42", "abc", "-3"} {
		age, err := parseAge(in)
		if err != nil {
			fmt.Println("error:", err)
			continue
		}
		fmt.Println("age:", age)
	}
}
Outputcompiled & run with real Go
age: 42
error: age "abc" is not a number
error: age out of range
Your turn

Add a check that rejects an empty string with its own message, before calling strconv.Atoi.

Error message style
Go error strings start with a lower-case letter and have no trailing full stop, because they are usually glued into longer messages: start server: read config: open app.yaml: no such file or directory. go vet and linters such as staticcheck flag capitalised error strings.

The "happy path" stays at the left margin: handle the error, return or continue, and carry on un-indented. This is the same return-early style from Module 02. It looks repetitive at first, but every place a call can fail is visible in the code, and reviewers can see that each failure was thought about.

Error you will hit

declared and not used: err

go
package main

import (
	"fmt"
	"strconv"
)

func main() {
	n, err := strconv.Atoi("42")
	fmt.Println(n)
}
# command-line-arguments
./main.go:9:5: declared and not used: err
Why the compiler said that

Go refuses to compile a local variable that is never read. With errors that is a feature: receiving an error and then forgetting to check it is caught at compile time.

The fix

Check the error. If you truly do not care (rare), discard it explicitly with _ so the decision is visible.

go
package main

import (
	"fmt"
	"strconv"
)

func main() {
	n, err := strconv.Atoi("42")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(n)
}
Error you will hit

too many return values

go
package main

import (
	"fmt"
	"os"
)

func loadConfig(path string) error {
	_, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	return nil
}

func main() {
	fmt.Println(loadConfig("app.yaml"))
}
# command-line-arguments
./main.go:11:15: too many return values
	have (nil, error)
	want (error)
Why the compiler said that

loadConfig is declared to return only error, but the early return passes two values — a habit carried over from functions that return (T, error). The compiler lists what you returned and what the signature wants.

The fix

Return exactly what the signature declares: here just err (better still, wrapped with context — next lesson).

go
package main

import (
	"fmt"
	"os"
)

func loadConfig(path string) error {
	_, err := os.ReadFile(path)
	if err != nil {
		return fmt.Errorf("load config: %w", err)
	}
	return nil
}

func main() {
	fmt.Println(loadConfig("app.yaml"))
}
02

Adding context: wrapping with %w

A bare no such file or directory in a log is almost useless: which file, and what were we doing? Each layer that passes an error up should add one short piece of context. fmt.Errorf with the %w verb does that and also wraps the original error, so it is still inside the new one and can be inspected later.

The result is a chain: the outer error holds the one it wrapped, which may hold another. errors.Unwrap(err) returns the next error down (or nil). You rarely call it yourself — errors.Is and errors.As walk the chain for you — but seeing the layers makes the idea concrete.

gomain.go
package main

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

func readConfig(path string) ([]byte, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("read config: %w", err)
	}
	return data, nil
}

func startServer() error {
	if _, err := readConfig("missing.yaml"); err != nil {
		return fmt.Errorf("start server: %w", err)
	}
	return nil
}

func main() {
	err := startServer()
	fmt.Println(err)

	// Walk the chain one layer at a time.
	for e := err; e != nil; e = errors.Unwrap(e) {
		fmt.Printf("  %T\n", e)
	}

	fmt.Println("file missing?", errors.Is(err, os.ErrNotExist))
}
Outputcompiled & run with real Go
start server: read config: open missing.yaml: no such file or directory
  *fmt.wrapError
  *fmt.wrapError
  *fs.PathError
  syscall.Errno
file missing? true

The final line of the chain is the operating-system error ENOENT. os.ErrNotExist matches it because syscall.Errno has an Is method.

Your turn

Change one %w to %v and run it again. The message is identical, but the chain stops at that layer and errors.Is now prints false.

VisualizeHow the wrapped message is builtStep 1 / 4
func readConfig(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
return data, nil
}
func startServer() error {
if _, err := readConfig("missing.yaml"); err != nil {
return fmt.Errorf("start server: %w", err)
}
return nil
}
Line 2

The file does not exist, so os.ReadFile returns a *fs.PathError.

Variables now
erropen missing.yaml: no such file or directory
All 4 steps as a table
StepLineWhat happenedVariables now
12The file does not exist, so os.ReadFile returns a *fs.PathError.err = open missing.yaml: no such file or directory
24%w formats the message AND keeps a pointer to the original error.returned = read config: open missing.yaml: ...
310The caller receives the wrapped error and checks it.err = read config: open missing.yaml: ...
411It adds its own layer on top. Three errors are now linked together.returned = start server: read config: open missing.yaml: no such file or directory

%w — wrap

  • The original error stays in the chain
  • errors.Is / errors.As can find it
  • The wrapped error becomes part of your API: callers may depend on it

%v — format only

  • Only the text is copied
  • The chain ends at your error
  • Use it when you deliberately hide an implementation detail (e.g. which database driver failed)
Wrap once per layer, with the operation
Good context names what this function was trying to do: fmt.Errorf("load user %d: %w", id, err). Do not repeat what the inner error already says, and do not log AND return the same error — it ends up in the logs twice. Either handle it (log, retry, fall back) or return it with context.
03

Sentinel errors, errors.Is and errors.As

A sentinel error is a package-level variable that callers compare against: io.EOF, sql.ErrNoRows, os.ErrNotExist, context.Canceled. By convention the names start with Err. They are the right choice when a caller needs to branch on one specific kind of failure — for example, turning "not found" into an HTTP 404.

Once errors are wrapped, err == ErrNotFound no longer works: the outer error is a different value. errors.Is(err, target) walks the whole chain and reports whether any layer equals the target. Always use errors.Is for sentinels — it works for wrapped and unwrapped errors alike.

gomain.go
package main

import (
	"errors"
	"fmt"
)

// Sentinel errors: package-level values callers can compare against.
var (
	ErrNotFound = errors.New("not found")
	ErrNoAccess = errors.New("permission denied")
)

var owners = map[string]string{"report.pdf": "ana", "notes.txt": "bo"}

func open(user, name string) (string, error) {
	owner, ok := owners[name]
	if !ok {
		return "", fmt.Errorf("open %s: %w", name, ErrNotFound)
	}
	if owner != user {
		return "", fmt.Errorf("open %s as %s: %w", name, user, ErrNoAccess)
	}
	return "contents of " + name, nil
}

func main() {
	for _, name := range []string{"report.pdf", "notes.txt", "draft.doc"} {
		body, err := open("ana", name)
		switch {
		case errors.Is(err, ErrNotFound):
			fmt.Println("404:", err)
		case errors.Is(err, ErrNoAccess):
			fmt.Println("403:", err)
		case err != nil:
			fmt.Println("500:", err)
		default:
			fmt.Println("200:", body)
		}
	}

	wrapped := fmt.Errorf("handler: %w", ErrNotFound)
	fmt.Println("== works?", wrapped == ErrNotFound)
	fmt.Println("Is works?", errors.Is(wrapped, ErrNotFound))
}
Outputcompiled & run with real Go
200: contents of report.pdf
403: open notes.txt as ana: permission denied
404: open draft.doc: not found
== works? false
Is works? true
Your turn

Add a sentinel ErrLocked returned for "notes.txt" before the owner check, and map it to 423 in the switch.

errors.As(err, &target) is the type-based version: it walks the chain looking for an error whose type matches target, and if it finds one it stores it in target. Use it when the error carries data you need — a status code, a field name, a retry-after duration.

gomain.go
package main

import (
	"errors"
	"fmt"
)

// A custom error type carries structured data, not just a message.
type HTTPError struct {
	Status int
	URL    string
}

func (e *HTTPError) Error() string {
	return fmt.Sprintf("GET %s: status %d", e.URL, e.Status)
}

func fetch(url string) error {
	if url == "/admin" {
		return &HTTPError{Status: 403, URL: url}
	}
	return nil
}

func sync(url string) error {
	if err := fetch(url); err != nil {
		return fmt.Errorf("sync failed: %w", err)
	}
	return nil
}

func main() {
	err := sync("/admin")
	fmt.Println(err)

	var httpErr *HTTPError
	if errors.As(err, &httpErr) { // finds the *HTTPError inside the chain
		fmt.Println("status code:", httpErr.Status)
		fmt.Println("retry?", httpErr.Status >= 500)
	}

	fmt.Println(sync("/home") == nil)
}
Outputcompiled & run with real Go
sync failed: GET /admin: status 403
status code: 403
retry? false
true
Your turn

Return a 503 for "/busy" and make main print retry? true for it.

Question you are askingUseExample
Is this (anywhere in the chain) that specific error value?errors.Iserrors.Is(err, fs.ErrNotExist)
Is there an error of this type in the chain? I need its fields.errors.Asvar pe *fs.PathError; errors.As(err, &pe)
What is the message?err.Error() / print itfor logs and users only — never parse it
Error you will hit

panic: errors: target must be a non-nil pointer

go
package main

import (
	"errors"
	"fmt"
)

type HTTPError struct{ Status int }

func (e *HTTPError) Error() string { return fmt.Sprint("status ", e.Status) }

func main() {
	err := fmt.Errorf("sync: %w", &HTTPError{Status: 503})

	var httpErr *HTTPError
	if errors.As(err, httpErr) {
		fmt.Println(httpErr.Status)
	}
}
panic: errors: target must be a non-nil pointer

goroutine 1 [running]:
errors.As({0x10483b4b0?, 0x66610ae24080?}, {0x10481bfe0?, 0x0?})
	.../src/errors/wrap.go:112 +0x1cc
main.main()
	./main.go:16 +0xa4
exit status 2
Why the compiler said that

errors.As needs somewhere to write the error it finds, so it takes a pointer to your variable. httpErr is itself a *HTTPError whose value is nil — passing it gives As a nil pointer to write through. The panic comes from inside the standard library (top frame), but the line to fix is the bottom frame, main.go:16. go vet catches this before you run it.

The fix

Pass the address of the variable: errors.As(err, &httpErr). The target is a pointer to a pointer — that is normal.

go
package main

import (
	"errors"
	"fmt"
)

type HTTPError struct{ Status int }

func (e *HTTPError) Error() string { return fmt.Sprint("status ", e.Status) }

func main() {
	err := fmt.Errorf("sync: %w", &HTTPError{Status: 503})

	var httpErr *HTTPError
	if errors.As(err, &httpErr) {
		fmt.Println(httpErr.Status)
	}
}
Never compare error strings
strings.Contains(err.Error(), "not found") breaks the day someone rewords a message or adds context. Messages are for humans; errors.Is and errors.As are for code.
04

Custom error types and errors.Join

Any type with an Error() string method is an error. Define a struct when callers need structured facts about the failure. Give it a pointer receiver and return &MyErr{...}, so every error value is distinct and errors.As targets are always *MyErr.

If your type wraps an underlying cause, add Unwrap() error. That one method plugs your type into the chain, so errors.Is and errors.As can see through it. errors.Join(errs...) (Go 1.20+) combines several errors into one — each on its own line when printed — and returns nil if they are all nil, which makes it perfect for validation.

gomain.go
package main

import (
	"errors"
	"fmt"
)

type FieldError struct {
	Field string
	Err   error // the underlying reason
}

func (e *FieldError) Error() string { return e.Field + ": " + e.Err.Error() }
func (e *FieldError) Unwrap() error { return e.Err } // lets errors.Is/As look inside

var ErrRequired = errors.New("is required")
var ErrTooLong = errors.New("is too long")

type Signup struct{ Name, Email string }

func validate(s Signup) error {
	var errs []error
	if s.Name == "" {
		errs = append(errs, &FieldError{"name", ErrRequired})
	}
	if s.Email == "" {
		errs = append(errs, &FieldError{"email", ErrRequired})
	} else if len(s.Email) > 20 {
		errs = append(errs, &FieldError{"email", ErrTooLong})
	}
	return errors.Join(errs...) // nil when errs is empty
}

func main() {
	fmt.Println(validate(Signup{"ana", "[email protected]"}))

	err := validate(Signup{"", ""})
	fmt.Println(err)
	fmt.Println("any required?", errors.Is(err, ErrRequired))

	err = validate(Signup{"bo", "[email protected]"})
	var fe *FieldError
	if errors.As(err, &fe) {
		fmt.Printf("first bad field: %s (%v)\n", fe.Field, fe.Err)
	}
}
Outputcompiled & run with real Go
<nil>
name: is required
email: is required
any required? true
first bad field: email (is too long)
Your turn

Add a rule that the email must contain @, with its own sentinel ErrInvalid. Check it with errors.Is in main.

Returning a typed nil
Never write var e *FieldError; ...; return e from a function whose result is error. A nil pointer inside an interface is not a nil interface, so the caller sees a non-nil error. Module 06 (lesson "The nil interface gotcha") walks through it step by step. Return a literal nil on success.
ToolWhen
errors.New / fmt.Errorf without %wthe caller only needs to know it failed and print why
sentinel var ErrX = errors.New(...)the caller branches on one well-known condition
custom type *XErrorthe caller needs data from the error (status, field, retry time)
fmt.Errorf("...: %w", err)adding context while passing an error up
errors.Joinreporting several independent failures at once
05

panic vs error, defer and recover

panic stops normal execution, runs the deferred calls of each function on the way up the stack, and — if nothing recovers — crashes the program with a stack trace. The runtime panics on bugs: index out of range, nil map write, nil pointer dereference, integer divide by zero.

The rule is simple: errors are for things that can go wrong in a correct program (missing file, bad user input, network down). Panics are for bugs — situations that mean the code itself is wrong, like an impossible switch case or a negative size passed by another part of your own program. Library functions should almost never panic on input from their caller. The MustX naming convention marks the exceptions: regexp.MustCompile panics on a bad pattern, which is fine for a constant pattern checked at start-up.

gomain.go
package main

import (
	"fmt"
	"regexp"
)

// mustPositive panics: a negative size here is a programmer bug, not bad input.
func mustPositive(n int) int {
	if n <= 0 {
		panic(fmt.Sprintf("size must be positive, got %d", n))
	}
	return n
}

// Compiled once at start-up: a bad pattern should stop the program immediately.
var slugRe = regexp.MustCompile(`^[a-z0-9-]+$`)

func main() {
	fmt.Println(mustPositive(8))
	fmt.Println(slugRe.MatchString("error-handling"))
	fmt.Println(slugRe.MatchString("Error Handling"))
}
Outputcompiled & run with real Go
8
true
false
Your turn

Call mustPositive(0) and read the panic message and stack trace it prints.

Error you will hit

panic: size must be positive, got -1

go
package main

import "fmt"

func mustPositive(n int) int {
	if n <= 0 {
		panic(fmt.Sprintf("size must be positive, got %d", n))
	}
	return n
}

func main() {
	fmt.Println(mustPositive(8))
	fmt.Println(mustPositive(-1))
}
8
panic: size must be positive, got -1

goroutine 1 [running]:
main.mustPositive(...)
	./main.go:7
main.main()
	./main.go:14 +0x94
exit status 2
Why the compiler said that

The first call printed 8; the second hit the panic. Read the trace top-down to see where it panicked (main.go:7, inside mustPositive) and follow it down to who called it with the bad value (main.go:14). Exit status 2 is what Go uses for an unrecovered panic.

The fix

Fix the caller — a panic like this is a bug report. If the value comes from outside the program (a flag, a request), validate it and return an error instead of panicking.

go
package main

import "fmt"

func checkSize(n int) (int, error) {
	if n <= 0 {
		return 0, fmt.Errorf("size must be positive, got %d", n)
	}
	return n, nil
}

func main() {
	for _, n := range []int{8, -1} {
		size, err := checkSize(n)
		if err != nil {
			fmt.Println("error:", err)
			continue
		}
		fmt.Println(size)
	}
}

recover: catching a panic at a boundary

recover() stops a panic, but only when called directly inside a deferred function. It returns the value passed to panic (or nil if there was no panic). Combined with a named result (Module 03), a deferred recover can turn a panic into an ordinary error.

gomain.go
package main

import "fmt"

// safeDivide turns a panic into an ordinary error at a boundary.
func safeDivide(a, b int) (result int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("recovered: %v", r)
		}
	}()
	return a / b, nil
}

func main() {
	fmt.Println(safeDivide(10, 2))
	fmt.Println(safeDivide(1, 0))
	fmt.Println("still running")
}
Outputcompiled & run with real Go
5 <nil>
0 recovered: runtime error: integer divide by zero
still running
Your turn

Remove the defer block. What does the program print now, and does still running appear?

VisualizesafeDivide(1, 0), step by stepStep 1 / 6
package main
import "fmt"
// safeDivide turns a panic into an ordinary error at a boundary.
func safeDivide(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r)
}
}()
return a / b, nil
}
func main() {
fmt.Println(safeDivide(10, 2))
fmt.Println(safeDivide(1, 0))
fmt.Println("still running")
}
Line 17

main calls safeDivide(1, 0) (the first call already printed 5 <nil>).

Variables now
a1
b0
All 6 steps as a table
StepLineWhat happenedVariables now
117main calls safeDivide(1, 0) (the first call already printed 5 <nil>).a = 1 b = 0
27The deferred closure is registered. It will run when the function exits — normally or by panic.result = 0 err = nil
3121 / 0 makes the runtime panic. Normal execution stops; the return never completes.panic = runtime error: integer divide by zero
48On the way out, the deferred function runs. recover() returns the panic value and stops the panic.r = runtime error: integer divide by zero
59It assigns the named result err. The function now returns normally with (0, err).result = 0 err = recovered: runtime error: integer divide by zero
618The program carries on.
When not to recover
Do not use panic/recover as a try/catch for ordinary failures — return errors. The legitimate uses are boundaries: net/http recovers a panicking handler so one bad request does not kill the server, and a worker pool may recover so one job cannot take down the others. Recovery only works in the goroutine that panicked: a panic in a goroutine you started with go crashes the whole program unless that goroutine recovers it itself.
06

Error handling in a small pipeline

Real programs combine all of this. Here is a tiny CSV-style importer: each line is name,amount. A bad line should not stop the import — we skip it, remember why, and report every problem at the end with its line number. A custom LineError adds the line number and Unwraps to the cause, so the caller can still ask "was any amount negative?" with errors.Is, or pull out the *strconv.NumError with errors.As.

gomain.go
package main

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

var ErrNegative = errors.New("negative amount")

type LineError struct {
	Line int
	Err  error
}

func (e *LineError) Error() string { return fmt.Sprintf("line %d: %v", e.Line, e.Err) }
func (e *LineError) Unwrap() error { return e.Err }

// parseLine: "name,amount" -> amount in cents.
func parseLine(s string) (string, int, error) {
	name, amt, ok := strings.Cut(s, ",")
	if !ok {
		return "", 0, fmt.Errorf("missing comma in %q", s)
	}
	cents, err := strconv.Atoi(strings.TrimSpace(amt))
	if err != nil {
		return "", 0, fmt.Errorf("amount: %w", err)
	}
	if cents < 0 {
		return "", 0, ErrNegative
	}
	return name, cents, nil
}

func total(input string) (int, error) {
	sum := 0
	var errs []error
	sc := bufio.NewScanner(strings.NewReader(input))
	for n := 1; sc.Scan(); n++ {
		_, cents, err := parseLine(sc.Text())
		if err != nil {
			errs = append(errs, &LineError{Line: n, Err: err})
			continue // keep going: report every bad line at once
		}
		sum += cents
	}
	if err := sc.Err(); err != nil {
		return 0, fmt.Errorf("read input: %w", err)
	}
	return sum, errors.Join(errs...)
}

func main() {
	input := "ana,1200\nbo,abc\ncy 300\ndee,-50\neve,800"
	sum, err := total(input)
	fmt.Println("total of good lines:", sum)
	if err != nil {
		fmt.Println("problems:")
		fmt.Println(err)
		fmt.Println("has negative?", errors.Is(err, ErrNegative))
		var numErr *strconv.NumError
		if errors.As(err, &numErr) {
			fmt.Println("bad number:", numErr.Num)
		}
	}
}
Outputcompiled & run with real Go
total of good lines: 2000
problems:
line 2: amount: strconv.Atoi: parsing "abc": invalid syntax
line 3: missing comma in "cy 300"
line 4: negative amount
has negative? true
bad number: abc
Your turn

Add an ErrEmptyName sentinel for lines like ,500, and print how many lines failed. (Hint: collect the errors in main, or count them inside total.)

  • Stop or continue? Decide per failure. A broken input file stops the whole run (sc.Err() returns early); one bad row is collected and skipped.
  • Context at each layer: parseLine says what was wrong, LineError says where.
  • Keep the chain with %w and Unwrap, so callers can branch without parsing strings.
  • Return partial results deliberately: total returns the sum of good lines and the joined error, and documents that both may be set. Most functions should return a zero value alongside an error; this is a conscious exception.
error
The built-in interface interface { Error() string }. nil means no error.
Sentinel error
A package-level error value such as io.EOF that callers compare against with errors.Is.
Wrapping
Creating a new error that contains the original, with fmt.Errorf("...: %w", err) or an Unwrap() error method.
Error chain
The sequence of errors reached by repeatedly unwrapping. errors.Is and errors.As search it.
errors.Is
Reports whether any error in the chain equals a target value.
errors.As
Finds the first error in the chain of a given type and stores it in a target pointer.
errors.Join
Combines several errors into one; returns nil if all are nil.
panic / recover
panic aborts the current goroutine's normal flow; recover, called in a deferred function, stops it and returns the panic value.
Quick check

A function returns fmt.Errorf("load: %w", sql.ErrNoRows). Which check is true?

Quick check

Where must recover() be called for it to stop a panic?

Frequently asked questions

Why does Go not have exceptions?
Go treats errors as ordinary return values so that every place a call can fail is visible in the code and handled next to the call. Panic exists, but it is reserved for bugs and unrecoverable situations, not normal failures like a missing file.
What is the difference between %w and %v in fmt.Errorf?
Both put the inner error's message in the new message. %w also wraps the inner error so errors.Is, errors.As and errors.Unwrap can find it; %v copies only the text and ends the chain.
When should I use errors.Is versus errors.As?
Use errors.Is to check for a specific error value such as io.EOF or your own ErrNotFound. Use errors.As when you need an error of a particular type so you can read its fields, such as a status code.

Finish the Go handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.