07 - Errors¶
What this session is¶
About an hour. You'll learn how Go handles things going wrong - opening a file that doesn't exist, parsing a number from text that isn't a number, network calls that fail. Go's approach is different from most other languages and one of the things that makes Go code recognizable on sight.
The big idea: errors are values¶
In many languages (Python, Java, JavaScript, Ruby), when something goes wrong the language throws an exception that flies up the call stack until someone catches it. If nobody catches it, the program crashes.
Go does not do this. In Go, a function that can fail returns an extra value: an error. The caller is expected to check it.
result, err := SomethingThatCanFail()
if err != nil {
// handle the failure
return err
}
// use result, now that we know it's safe
That if err != nil { ... } block is the most-typed pattern in all of Go. Some people love it; some people complain about it. Either way, you can't read Go code without it.
A real example¶
The standard library has strconv.Atoi, which converts a string to an integer ("Atoi" = "ASCII to integer"). It might fail, because "hello" isn't a number. So it returns two things:
package main
import (
"fmt"
"strconv"
)
func main() {
n, err := strconv.Atoi("42")
if err != nil {
fmt.Println("could not parse:", err)
return
}
fmt.Println("got the number:", n)
n2, err := strconv.Atoi("hello")
if err != nil {
fmt.Println("could not parse:", err)
return
}
fmt.Println("got the number:", n2) // never reached
}
Type and run. Output:
What's happening:
strconv.Atoi("42")succeeds.errisnil(the special "nothing" value). We skip theifand usen.strconv.Atoi("hello")fails.erris a non-nil error value with a useful message. We enter theif, print it, andreturnfrom main - which ends the program.
That nil thing is new. nil is Go's word for "no value, nothing here." When a function returns (T, error):
- If everything was fine: the
Tis the real result anderrorisnil. - If something failed: the
Tis usually the zero value (don't trust it), anderroris the actual error.
You always check err != nil before trusting the other return value. Always. There are no exceptions to this rule for code you write.
What is an error, exactly?¶
An error is just a value with a method called Error() that returns a string. You'll learn the details (it's an "interface", page 11) later. For now: think of error as a type that knows how to describe itself. err.Error() gives you the string; fmt.Println(err) calls it automatically.
Making your own errors¶
You'll write functions that can fail. They need to return errors.
The simplest way:
import "errors"
func divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("divide by zero")
}
return a / b, nil
}
errors.New("...") creates a brand-new error with the given message. Use it for simple cases.
The fancier way uses fmt.Errorf, which is like fmt.Sprintf but produces an error:
import "fmt"
func divide(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("cannot divide %d by zero", a)
}
return a / b, nil
}
The advantage: you can include variables in the error message, exactly like Println.
Wrapping errors with %w¶
Often a function fails because another function it called failed. You want to include the deeper error in your own error message:
import (
"fmt"
"strconv"
)
func parseAge(input string) (int, error) {
n, err := strconv.Atoi(input)
if err != nil {
return 0, fmt.Errorf("parsing age %q: %w", input, err)
}
if n < 0 {
return 0, fmt.Errorf("age cannot be negative, got %d", n)
}
return n, nil
}
The %w placeholder wraps the original error inside your new one. Why this matters:
- The error message includes everything: "parsing age \"hello\": strconv.Atoi: parsing \"hello\": invalid syntax". Helpful when debugging.
- Other code can later unwrap it (
errors.Is,errors.As) to check whether a specific underlying error happened. You'll see this in real codebases.
For now, the rule: when you return an error that was caused by another error, wrap it with %w.
The pattern, end-to-end¶
A function that does several things, any of which can fail:
func loadConfig(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("reading %s: %w", path, err)
}
cfg, err := parseConfig(data)
if err != nil {
return Config{}, fmt.Errorf("parsing %s: %w", path, err)
}
if err := cfg.Validate(); err != nil {
return Config{}, fmt.Errorf("validating %s: %w", path, err)
}
return cfg, nil
}
Three things, three if err != nil blocks, three wrapped error messages. Every error tells you which step failed and what happened underneath. This shape - try a step, check, maybe wrap and return - is the rhythm of Go.
It is verbose. People who come from exception-based languages often dislike this initially. The argument for it: every failure path is right there in the code, visible and explicit, not hidden in a throws clause or a catch block somewhere else.
What about panics?¶
You'll occasionally see panic("message") in Go code. A panic is a program-level crash - used for "this should never happen, the program is broken." Examples: dividing by zero (the language panics for you), trying to read index 99 of a 3-element slice, dereferencing a nil pointer.
Don't use panic for normal failure cases. "File not found" isn't a panic; it's an error. Panic is for "the world is broken and there's no recovering." You'll see it in init code (a service can't start without its config) and in development assertions ("this case should be impossible - if it happens, I want to know loudly").
There's a way to catch panics (recover) but you'll almost never need it. Use errors for almost everything.
Exercise¶
In a new file parse.go:
Write a function parsePositive(s string) (int, error) that:
- Parses
sas an integer usingstrconv.Atoi. - If
Atoifails, returns the error wrapped with%wand a message like"parsePositive %q: %w". - If the parsed number is less than or equal to zero, returns an error like
"parsePositive: number must be positive, got %d"(usefmt.Errorf, no wrap because there's no underlying error to wrap). - If everything is fine, returns the number and
nil.
In main, call parsePositive with each of these inputs and print the result:
- "42" → expect 42, no error
- "hello" → expect parse error
- "-5" → expect "must be positive" error
- "0" → expect "must be positive" error
- "100" → expect 100, no error
Loop over them with a slice: inputs := []string{"42", "hello", "-5", "0", "100"}.
What you might wonder¶
"Why isn't this try/catch like other languages?"
Design choice. Exceptions hide control flow - a function call may secretly jump to a handler 10 frames up the stack. Go's authors decided that making every failure path explicit was worth the extra typing. You don't have to agree with the choice; you do have to read the code.
"What's nil?"
Go's "no value" for types that can be absent - errors, pointers (page 08), maps that haven't been made yet, slices that haven't been initialized, channels. nil compared with == lets you check for "nothing here." if err != nil literally means "if there is an error."
"Is if err != nil really every other line?"
Roughly, yes. Modern Go has a few patterns to reduce it (helpers, error groups), but expect to see and write the pattern often. You'll stop noticing it in a few weeks.
"What's %q?"
A fmt placeholder that prints a value in quoted form: "hello" rather than hello. Useful in error messages so you can tell whitespace and empty strings apart.
Done¶
You can now:
- Recognize that Go functions that can fail return (T, error).
- Write the if err != nil { return ..., err } pattern.
- Create errors with errors.New and fmt.Errorf.
- Wrap one error inside another with %w.
- Tell errors apart from panics (and know when to use each).
You've now seen Go's most distinctive idiom. Real Go code is mostly: data shaping (slices, maps, structs), decisions and loops, function composition, and error checking. You have all five.
Next page: pointers - how Go lets you share data and let functions mutate things.
Next: Pointers and memory → 08-pointers-and-memory.md