In Go, an error is just a value. Not a special crash, not a thing you “throw” — a regular value that a function returns alongside its result. We check it, and we decide what to do.
In simple language: instead of “try something and catch the explosion later,” Go says “do something, and tell me right here if it went wrong.”
This is called errors as values, and it’s one of the most distinctly Go things there is.
Why no exceptions?
Most languages (Java, Python, JS) use exceptions for errors. You throw, and somewhere far away a catch handles it. The problem? The error path is invisible. You can’t tell by reading a function whether it might blow up.
Go’s authors hated that. They wanted the error handling to be right there in the code, impossible to ignore.
So in Go, normal failures (file not found, network timeout, bad input) are not exceptions. They’re returned values you must deal with on the spot.
The error interface
Here’s the whole thing. The error type is just an interface with one method:
type error interface {
Error() string // returns the error message as a string
}
That’s it. Anything with an Error() string method is an error. No magic.
The idiomatic pattern: if err != nil
By convention, functions that can fail return the error as their last return value. We check it immediately.
f, err := os.Open("config.yaml") // returns (*os.File, error)
if err != nil {
// something went wrong — handle it and bail out
return err // pass the problem up to whoever called us
}
defer f.Close() // only runs if we got here, meaning err was nil
// ... use f safely, we know it's valid now
Think of it like a bouncer at every door: check the error, and if it’s there, you don’t get to touch the result.
Creating errors
Two everyday tools.
errors.New — for a simple static message:
import "errors"
func withdraw(balance, amount int) (int, error) {
if amount > balance {
return 0, errors.New("insufficient funds") // plain message
}
return balance - amount, nil // nil means "no error, all good"
}
fmt.Errorf — when we want to drop variables into the message:
import "fmt"
func getUser(id int) error {
// %d, %s etc. work just like Printf
return fmt.Errorf("user %d not found", id)
}
Returning errors up the stack
We rarely handle an error where it happens. Usually we just hand it upward, and the top-level caller (like an HTTP handler) decides what to show the user. Each layer either handles the error or returns it.
func loadConfig() (*Config, error) {
data, err := os.ReadFile("config.json")
if err != nil {
return nil, err // can't read? give up, pass it up
}
return parse(data) // parse also returns (*Config, error)
}
Sentinel errors
Sometimes we need to check for a specific, known error. Like “did we hit the end of the file?” For that we use a sentinel error — a predefined error value we compare against.
The classic example is io.EOF (“end of file”). When a reader is done, it returns exactly that value, and we check for it by identity:
import "io"
for {
n, err := reader.Read(buf)
if err == io.EOF { // the agreed-upon "we're done" signal
break // not a real problem, just stop reading
}
if err != nil {
return err // a real error this time
}
process(buf[:n])
}
A sentinel is just a package-level variable like var ErrNotFound = errors.New("not found") that callers compare against. (In modern Go we compare with errors.Is instead of == — more on that in the wrapping note.)
The “if err != nil everywhere” criticism — honestly
Yeah, it’s real. Go code is littered with if err != nil { return err }. Critics call it noisy and repetitive, and they’re not wrong — you’ll type it hundreds of times.
But here’s the trade-off Go makes on purpose:
- Errors are visible. You can see every failure path by reading top to bottom. No hidden jumps.
- Errors are hard to ignore. The compiler complains about unused variables, nudging us to actually check.
- The control flow is dead simple. No invisible stack unwinding, no “where does this catch?” puzzles.
So the verbosity is the price for explicitness. Most Go devs grow to like it — the boilerplate is boring, but boring is predictable, and predictable is good in production.
Interview soundbite
In Go, errors are ordinary values returned as the last return value, and error is just an interface with a single Error() string method. We handle them inline with if err != nil, create them with errors.New or fmt.Errorf, and check known cases against sentinel values like io.EOF. The famous if err != nil boilerplate is the deliberate price for making every failure path explicit and impossible to silently ignore.