Every language this curriculum has covered so far uses exceptions — an error can be thrown from deep inside a call stack and caught several frames away, invisibly, unless a reader traces every possible throw site. Go has no exceptions for ordinary error handling at all, and that omission is this lesson's entire subject.
3 min read
func readConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config: %w", err)
}
config, err := parseConfig(data)
if err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
return config, nil
}Lesson 3 already established that Go functions can return multiple values — error handling is simply the dominant, idiomatic use of that feature. A function that can fail returns (result, error), and the error is nil on success, a real value describing what went wrong otherwise. There's no throw, no try/catch, and no hidden control-flow jump — a caller calls a function, gets back an error value exactly like any other return value, and decides what to do with it using an ordinary if statement.
if err != nil {
return nil, err
}This exact three-line pattern appears after nearly every fallible function call in idiomatic Go code — a famous, often-mocked amount of repetition compared to a single try block wrapping several calls in Python or JavaScript. The trade-off this buys: every single point where an error could occur is visible, explicitly, directly in the code reading top to bottom — there's no invisible possibility of an exception surfacing from three function calls deep that a reader has to already know about to anticipate. JavaScript and Python's exception model optimizes for concise happy-path code at the cost of error paths being implicit; Go inverts that trade entirely.
fmt.Errorf("reading config: %w", err) wraps the original error inside a new one that adds context ("this failed while reading a config") without losing the original error's identity — %w (as opposed to %v, which would just format it as plain text) keeps the original error accessible via errors.Unwrap or errors.Is, so code higher up the call stack can still check "was this specifically a file-not-found error" even after several layers of wrapping added their own context along the way.
panic and recover: Go does have something exception-like, deliberately reserved for exceptional casesfunc mustParse(s string) int {
n, err := strconv.Atoi(s)
if err != nil {
panic("invalid number: " + s) // reserved for truly unrecoverable situations
}
return n
}Go does have panic (stops normal execution, unwinds the call stack) and recover (catches a panic, if called inside a deferred function) — but idiomatic Go reserves these specifically for programmer errors and genuinely unrecoverable situations (an index out of bounds, a nil pointer dereference), never for ordinary, expected failure modes like "the file doesn't exist" or "the network request timed out," which always use the (result, error) pattern instead. Using panic for an ordinary, anticipatable failure is considered a real anti-pattern in Go, precisely because it reintroduces the invisible-control-flow problem this lesson's explicit-errors approach was designed to avoid.
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. How does idiomatic Go handle a function that can fail?
2. What does wrapping an error with fmt.Errorf's %w verb preserve, that %v does not?
3. When is it idiomatic to use panic/recover in Go instead of the (result, error) pattern?
Go