Errors & I/O - Errors Are Values
If you're coming from a language with exceptions, Go's error handling will feel either refreshing or relentless, and usually both. There's no try/catch, no invisible stack-unwinding to a handler three floors up:
An error is just a value. A function that can fail returns one, right alongside its result, and you deal with it then and there.
An error isn't a special control-flow event - it's data. The function hands back "here's the answer, and here's what went wrong (or nil if nothing did)." That's why if err != nil is everywhere in Go: the language insists you look at the failure at the exact spot it happened, while you still have context to act.
The if err != nil pattern
By convention, a Go function that can fail returns its result and an error as its last value. Check the error immediately; if it's not nil, something went wrong.
📝 Terminology. error is a built-in interface - anything with an Error() string method is an error. The zero value of an error is nil, meaning "no error." So err != nil literally reads as "an error is present."
strconv.Atoi turns a string into an int, and it can fail (the string might not be a number):
package main
import (
"fmt"
"strconv"
)
func main()
$ go run main.go
parsed: 42
Atoi returned two values: the parsed number 42 and an error. We checked err right away; it was nil, so we trusted n and printed it. Passing "oops" instead would make err non-nil (strconv.Atoi: parsing "oops": invalid syntax), taking the error branch. Result and error come back together, and you decide what to do with the failure on the spot.
⚠️ Gotcha - ignoring the error. Go lets you discard a return value with _, and the single most common Go mistake is doing that to the error:
n, _ := strconv.Atoi(userInput) // DON'T: if userInput is garbage, n is silently 0
You threw the error away. If userInput was "abc", Atoi returned 0 for n and a non-nil error explaining why - but ignoring it means holding 0 as if the parse succeeded. The bug surfaces later, far from the cause, as a mysterious zero. The whole point of errors-as-values is that you see them; _ throws that away. (The errcheck linter, part of golangci-lint in the next phase, catches exactly this.)
Returning errors from your own functions
When your function can fail, follow the same convention: return (result, error). Pass a downstream error back up, or create your own with errors.New or fmt.Errorf.
package main
import (
"errors"
"fmt"
)
func half(n int) (int, error)
func main()
$ go run main.go
half(8) = 4
half(7): number is odd
half returned (result, nil) on success and (0, an error) on failure. The caller checked err each time: for 8 it printed the result, for 7 the error. The failed case still returns a real int (0) - Go requires all return values, so convention is a zero/empty result alongside a non-nil error, and the caller knows not to trust the result when the error is set.
Wrapping errors with %w
A bare "file not found" tells you what but not where in your program it happened. Wrapping adds your context while keeping the original error intact underneath, via fmt.Errorf and the special %w verb.
Why this matters. %w doesn't just stuff the old message into a new string - it links the new error to the original so tools can still dig it out later (the errors.Is/errors.As trick below). You get a readable chain: high-level context on the outside, root cause on the inside.
package main
import (
"errors"
"fmt"
)
var errNotFound = errors.New("not found")
func loadUser(id int) error
func main()
$ go run main.go
loadUser(7): not found
fmt.Errorf built a new message - loadUser(7): not found - but %w also kept a hidden pointer to the original errNotFound. The message reads top-down (context first, root cause last), and the original error is still recoverable. Use %w when callers might need the cause; use plain %v (just formats the text) when you only need it readable.
Inspecting errors: errors.Is and errors.As
Wrapping forms a chain, so you often need to ask: "is this (anywhere in the chain) a specific known error?" and "is this a specific error type, and can I get at its fields?" That's errors.Is and errors.As.
errors.Is(err, target)- true iferr, or anything it wraps, is that sentinel value. Use it to compare against a known error likeerrNotFoundoros.ErrNotExist. (Don't use==- that only checks the outermost error and misses wrapped ones.)errors.As(err, &target)- true iferr, or anything it wraps, is of a given type; if so it fillstargetso you can read the type's fields.
package main
import (
"errors"
"fmt"
)
var errNotFound = errors.New("not found")
func loadUser(id int) error
func main()
$ go run main.go
yes, this was a not-found error
Even though err's text was the wrapped loadUser(7): not found, errors.Is walked the chain, found errNotFound underneath, and matched it. A plain err == errNotFound would return false - the outer wrapper isn't equal to the sentinel - exactly why errors.Is exists. Reach for errors.As (a pointer to a variable of the error type) when you need the structured data inside, not just "is it this kind."
Reading files: os and bufio
File and stream handling lives mostly in os and bufio, and every operation returns an error you check. For a whole small file, os.ReadFile gives you the bytes in one call. For reading line by line without loading it all into memory, bufio.Scanner is the standard tool.
package main
import (
"bufio"
"fmt"
"os"
)
func main()
$ cat notes.txt
buy milk
call dentist
$ go run main.go
line: buy milk
line: call dentist
os.Open returned the open file and an error; we checked it, then set up defer f.Close() so the file closes no matter how the function exits (an idiom covered in Phase 9). bufio.NewScanner wrapped the file; scanner.Scan() returned true per line and false at end-of-file, and scanner.Text() gave the line's contents. After the loop we called scanner.Err() - Scan() returns false both for a clean EOF and a read error, and only scanner.Err() tells the two apart.
⚠️ Gotcha. scanner.Scan() returning false does not mean "success." It means "stop looping" - a normal EOF or a real I/O error. Always check scanner.Err() after the loop, or a disk error mid-read will look exactly like a clean finish.
panic and recover - the rare exception
panic is Go's exception-like mechanism: it stops normal flow, unwinds the stack running deferred functions, and crashes the program with a stack trace. recover (only meaningful inside a defer) can catch a panic and stop the unwind. Go wants you to almost never use them for ordinary errors.
When it's appropriate. Use panic for truly unrecoverable programmer errors - an impossible state, a violated invariant, a config too broken to start. Use ordinary error values for normal, expected failures (file missing, bad input, network down). The dividing line: "is this a bug, or a Tuesday?" Bugs may panic; Tuesdays return an error.
package main
import "fmt"
func safeDivide(a, b int) (result int, err error)
func main()
$ go run main.go
err: recovered from: runtime error: integer divide by zero
10 / 0 triggered a runtime panic, which would normally crash the program. But the deferred function ran during the unwind, called recover() (returning the panic value instead of nil), and assigned a normal error to the named return value err - converting the panic back into an ordinary error the caller could check. This pattern - recover at a boundary, turn panic into error - is the main legitimate use of recover, reserved for guarding against bugs, not expected failures.
⚠️ Gotcha. Don't use panic/recover as cheap exceptions to dodge if err != nil. A panic that escapes a goroutine crashes the entire program - recover only works in the same goroutine that's unwinding. Errors-as-values is the path; panic is the emergency exit.
Recap
- Errors are values - a fallible function returns
(result, error); you checkif err != nilright there, where you have the context to act. - Return them - pass downstream errors up; create your own with
errors.New/fmt.Errorf. Return a zero result alongside a non-nil error. - Wrap with
%w-fmt.Errorf("doing X: %w", err)adds your context while keeping the original error recoverable. - Inspect with
errors.Is/errors.As- match a sentinel anywhere in the chain (Is), or pull out a specific error type and its fields (As). Don't use==. - I/O -
os.Open+bufio.Scannerreads line by line;defer f.Close(); always checkscanner.Err()after the loop. panic/recover- Go's real exceptions, reserved for unrecoverable bugs, not everyday failures. ⚠️ Never ignore an error with_.
You now write code that fails clearly. Next: stepping back from the language to the toolbox around it - the batteries-included commands (go build, go test, go fmt) that make Go projects low-fuss to work in.
← Phase 6: Goroutines & Channels · Phase 8: The Ecosystem & Tooling →
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 2 questions
1. In Go, an error is...
2. The `%w` verb in fmt.Errorf does what?