No try/except. Errors are values. Yes, you check every one - and you'll love it.
Go has no exceptions. Not "Go discourages exceptions" - it literally does not have them. There is no raise, no try, no except, no implicit stack unwinding. Instead:
error as their last return value.if err != nil { ... }.This sounds painful. It isn't. Once you get used to it, you know exactly where things can fail, what happens when they do, and where errors are handled. Python code with exceptions can fail anywhere and the handler might be 10 stack frames away.
def load_config(path):
try:
with open(path) as f:
return json.load(f)
except FileNotFoundError:
raise # re-raise
except json.JSONDecodeError as e:
raise ValueError(
f"bad config: {e}"
) from e
# caller - might not even know
# it can throw
cfg = load_config("app.json")
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf(
"loading config: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf(
"parsing config: %w", err)
}
return &cfg, nil
}
// caller must deal with it
cfg, err := loadConfig("app.json")
if err != nil { /* handle */ }
error is just an interface built into Go. Here's the entire definition:
type error interface {
Error() string
}
That's it. Anything with an Error() string method satisfies the error interface. nil means "no error." Non-nil means something went wrong - call .Error() to get the message.
import "errors"
err := errors.New("something failed")
fmt.Println(err.Error())
// "something failed"
import "fmt"
err := fmt.Errorf(
"got %d items, want %d",
got, want,
)
// "got 3 items, want 5"
| Function | Returns | Use when |
|---|---|---|
errors.New("msg") | A simple error with fixed text | One-time, no formatting needed |
fmt.Errorf("msg %v", x) | A formatted error | You want to include variable values |
fmt.Errorf("ctx: %w", err) | A wrapped error (see next section) | You want to preserve the original error for callers to inspect |
nil as the error is how you say "success." It's not a special case - it's just the zero value of the error interface.
Convention: the error is always the last return value. If there's no error to return, return nil.
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
// caller
result, err := divide(10, 0)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("result:", result)
A more realistic example with a struct return:
type Config struct {
Host string
Port int
}
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("loading config: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
return &cfg, nil
}
Go code reads top-to-bottom. The pattern is: call, check, return if bad, continue if good. This is called "guard clauses" - you handle the failure case immediately, and the happy path runs straight down without nesting.
def process(path):
try:
data = open(path).read()
parsed = json.loads(data)
result = transform(parsed)
return result
except Exception as e:
# one handler for all errors
log(e)
return None
func process(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
parsed, err := parse(data)
if err != nil {
return "", err
}
return transform(parsed), nil
}
func loadConfig(path string) (cfg *Config, err error) - then inside you can just write return without listing the values. Use sparingly; it helps when a function has many exit paths.
When you get an error from a lower-level function, you usually want to add context before passing it up. The %w verb in fmt.Errorf does exactly this - it wraps the original error so callers can still inspect it.
// without wrapping - context lost, original gone
return nil, errors.New("failed to load config")
// with wrapping - context added, original preserved
return nil, fmt.Errorf("loading config: %w", err)
// the error message chains together:
// "loading config: reading file: permission denied"
import (
"errors"
"io/fs"
)
// wrappedErr = "loading config: reading file: permission denied"
if errors.Is(wrappedErr, fs.ErrPermission) {
// true - even though it's buried inside two wrappers
fmt.Println("access denied - check file permissions")
}
// errors.Is works with == too - but only traverses wrapped chains
if errors.Is(err, ErrNotFound) { // sentinel check
// handles ErrNotFound anywhere in the chain
}
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
// pathErr is now populated with the *fs.PathError from the chain
fmt.Printf("failed on path: %s\n", pathErr.Path)
}
errors.Is automatically opens all the envelopes until it finds what you're looking for.
errors.As is like saying "I don't just want to know IF there's a note from the worker - I want to actually read that note and see the details."
There are two common patterns for errors that callers need to handle differently.
A sentinel error is a package-level variable callers can compare against. Use these when the error identity matters but no extra detail is needed.
package store
// exported sentinel - callers can check for this specifically
var ErrNotFound = errors.New("not found")
var ErrConflict = errors.New("resource already exists")
func (s *Store) Get(id string) (*Record, error) {
r, ok := s.data[id]
if !ok {
return nil, ErrNotFound
}
return r, nil
}
// caller
record, err := store.Get("abc")
if errors.Is(err, store.ErrNotFound) {
// create it instead
}
A typed error is a struct with an Error() string method. Use these when callers need to inspect the error beyond just its identity.
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error: %s - %s", e.Field, e.Message)
}
func validateUser(u User) error {
if u.Age < 0 {
return &ValidationError{Field: "age", Message: "must be non-negative"}
}
return nil
}
// caller extracts the type with errors.As
var vErr *ValidationError
if errors.As(err, &vErr) {
fmt.Printf("field %q: %s\n", vErr.Field, vErr.Message)
}
| Pattern | Use when | Caller checks with |
|---|---|---|
| Sentinel error | Binary case: "it was not found" (no extra details needed) | errors.Is(err, ErrNotFound) |
| Typed error | Caller needs to inspect fields (which field failed, what path, etc.) | errors.As(err, &target) |
| Plain fmt.Errorf | Error is only for humans to read, callers won't programmatically inspect | Just print or log it |
Go does have a crash mechanism: panic. But it is not an exception system. It is for truly unrecoverable situations - a bug that means the program's state is corrupt and continuing would be dangerous.
// panic immediately stops this goroutine's execution
// and unwinds the stack, running any deferred functions
panic("index out of range - this is a bug, not user error")
// recover() - must be called inside a deferred function
// catches a panic and stops the unwind
defer func() {
if r := recover(); r != nil {
fmt.Println("caught panic:", r)
}
}()
# totally normal in Python
try:
value = d[key]
except KeyError:
value = default
# StopIteration is how
# iterators signal "done"
for item in iterable:
process(item)
// WRONG - don't panic for logic
func get(m map[string]int, k string) int {
v, ok := m[k]
if !ok { panic("not found") } // NO
return v
}
// RIGHT - panic for programming bugs
if dep == nil {
// nil dep is a bug in my code
panic("dep must not be nil")
}
panic is breaking the glass on a fire alarm box. recover is the firefighter who lives inside the wall and can catch the broken glass before it hits the floor.
How Go error handling looks in real code, not toy examples.
An HTTP handler that wraps errors with context at each layer and returns a clean 500 to the client:
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id")
user, err := h.store.GetUser(r.Context(), userID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
http.Error(w, "user not found", http.StatusNotFound)
return
}
// log the full chain for debugging, hide internals from client
h.log.Error("getting user", "err", err, "user_id", userID)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := json.NewEncoder(w).Encode(user); err != nil {
// can't send a 500 at this point - headers already sent
h.log.Error("encoding response", "err", err)
}
}
// deeper in the stack, wrapping adds context at each layer:
func (s *Store) GetUser(ctx context.Context, id string) (*User, error) {
row, err := s.db.QueryRowContext(ctx, "SELECT ...", id)
if err != nil {
return nil, fmt.Errorf("querying user %s: %w", id, err)
}
// ... scan row ...
return &user, nil
}
A CLI tool that checks the specific error type to give a helpful message, and exits with a non-zero code so shell scripts can detect the failure:
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: myapp <config-file>")
os.Exit(1)
}
path := os.Args[1]
cfg, err := loadConfig(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
fmt.Fprintf(os.Stderr,
"config file %q not found\nCreate it with: myapp init\n", path)
} else {
fmt.Fprintf(os.Stderr, "error loading config: %v\n", err)
}
os.Exit(1) // non-zero: signals failure to the shell
}
run(cfg)
}
$ myapp config.yaml
config file "config.yaml" not found
Create it with: myapp init
$ echo $?
1
A Kubernetes controller reconciler distinguishing between transient errors (requeue) and terminal conditions (do not retry):
func (r *MyReconciler) Reconcile(
ctx context.Context, req ctrl.Request,
) (ctrl.Result, error) {
obj := &myv1.MyResource{}
if err := r.client.Get(ctx, req.NamespacedName, obj); err != nil {
if apierrors.IsNotFound(err) {
// object deleted - nothing to do, don't requeue
return ctrl.Result{}, nil
}
// transient error (network blip, API server unavailable)
// return err: controller-runtime will requeue automatically
return ctrl.Result{}, fmt.Errorf("getting object: %w", err)
}
if err := r.doWork(ctx, obj); err != nil {
var permErr *PermanentError
if errors.As(err, &permErr) {
// this will never succeed - record status, don't requeue
r.setStatusFailed(ctx, obj, permErr.Reason)
return ctrl.Result{}, nil
}
// transient - requeue with backoff
return ctrl.Result{RequeueAfter: 30 * time.Second}, err
}
return ctrl.Result{}, nil // success
}
The pattern here is: (ctrl.Result{}, err) means "something went wrong, please retry." (ctrl.Result{}, nil) means "done." (ctrl.Result{RequeueAfter: ...}, err) means "retry but give it a moment." The error type decides which path to take.
_ is risky - did you mean to handle it?
result, _ := doThing() silently discards the error. Sometimes that's intentional (you know this specific call can't fail). But if you're not sure, that underscore is hiding a bug. Add a comment: // error ignored: X is always valid here so future-you knows it was deliberate.
Convention: if you return an error, set the other return values to their zero values (nil, 0, ""). Some callers check the error first and ignore the value; others might use the value even if err != nil. Don't make them guess.
log.Fatal in library code - return the error, let the caller decide.
log.Fatal calls os.Exit(1) - there's no recovering from it, no cleanup, no choice. If your code is a library or reusable package, you have no idea what the caller wants to do when something fails. Return the error. The caller - usually a main() function - can decide whether to fatal, retry, or fall back.
if err != nil { return err } loses context - wrap instead.
Bare return err means the caller sees "permission denied" with no idea where it came from. Wrap it: return fmt.Errorf("reading config file: %w", err). Now the error says "reading config file: permission denied" and the call site is obvious. The %w verb preserves the original for errors.Is/errors.As.
Coming from Python, the first instinct is to reach for panic like you'd reach for raise. Don't. Panics are for bugs (programmer errors), not expected failures (user errors, missing files, network timeouts). Using panic for control flow makes your code unpredictable and hard to use as a library.
== only works for sentinel errors - use errors.Is for wrapped ones.
err == ErrNotFound fails if the error was wrapped with fmt.Errorf("...: %w", ErrNotFound) - the wrapper is a different pointer. errors.Is(err, ErrNotFound) traverses the chain and finds it. Always use errors.Is unless you wrote the error yourself and know it was never wrapped.
| Term | Meaning |
|---|---|
error | A built-in interface with one method: Error() string. The universal type for failure information in Go. |
| error interface | The full definition: type error interface { Error() string }. Any type implementing this method satisfies the interface. |
| sentinel error | A package-level variable (var ErrNotFound = errors.New(...)) that callers compare against to detect specific conditions. |
| typed error | A struct type that implements the error interface. Callers extract it with errors.As to inspect its fields. |
| wrapping | Adding context to an error while preserving the original: fmt.Errorf("context: %w", err). The original is still accessible via the chain. |
%w verb | The format verb in fmt.Errorf that wraps an error instead of just stringifying it. Enables errors.Is and errors.As to traverse the chain. |
errors.Is | Checks if a specific error (or any error in its wrapped chain) matches a target value. Handles wrapped errors correctly; use this instead of ==. |
errors.As | Finds the first error in the chain that matches a target type and assigns it to a pointer. Used to extract typed errors from wrapped chains. |
errors.Unwrap | Returns the next error in a wrapped chain. Usually called indirectly via errors.Is/errors.As, rarely called directly. |
panic | Stops the current goroutine's normal execution and begins unwinding the stack. For unrecoverable programmer errors only - not for expected failures. |
recover | Called inside a deferred function; catches a panic and returns the value passed to panic. Stops the stack unwind. |
| defer-recover idiom | defer func() { if r := recover(); r != nil { ... } }() - the standard pattern for catching panics at a boundary (e.g., HTTP handler wrapping plugin code). |
Backstory and pitfalls that make this chapter stick.
type error interface { Error() string }. Anything with an Error() string method satisfies it.errors.New("not found"). From the std lib's errors package.%w: fmt.Errorf("loading config: %w", err).errors.Is(err, io.EOF). Walks the wrap chain.var pathErr *fs.PathError; errors.As(err, &pathErr).if err != nil { return err } repetition is intentional - it makes the cost of errors visible at every callsite. Go 2's experimental try proposal (2019) was rejected after community feedback - the verbosity is a feature, not a bug. Modern Go (1.13+) added wrapping with %w which makes the repetition more productive: each return adds context to the error chain.
find(1) returns a typed-nil (*NotFoundError)(nil). Does err != nil enter the error branch?
package main
import "fmt"
type NotFoundError struct{}
func (NotFoundError) Error() string { return "not found" }
func find(id int) error {
if id == 0 {
return nil
}
var e *NotFoundError
return e // typed nil - the trap
}
func main() {
err := find(1)
if err != nil {
fmt.Println("error:", err)
} else {
fmt.Println("no error")
}
}YES - it enters the error branch. Output: error: <nil>.
This is the typed-nil interface trap. find returns error (an interface). When you assign a typed-nil pointer ((*NotFoundError)(nil)) to an interface, the interface itself is NOT nil because it has a type assigned. Only when BOTH the type AND value are nil does an interface compare equal to nil.
Fix: always return raw nil for the error path, never a typed nil:
func find(id int) error {
if id == 0 {
return nil
}
return nil // not `var e *NotFoundError; return e`
}_ = sendToBank()
$3M reconciliation + SEC inquiry
A payment processor's daily settlement job had this pattern in 30 places:
_ = sendToBank(payment) // silently discards errorsThe leading underscore silently discarded errors. When the bank's API started returning 500s due to a partial outage, the code didn't notice. 6 weeks passed before someone noticed payments stopped settling. The money sat in a holding account. Reconciliation took 3 months. SEC questions followed.
Lesson: Never silently discard an error. Even if you genuinely don't care, log it: if err := sendToBank(payment); err != nil { log.Printf("ignoring: %v", err) }. Linters like errcheck catch the underscore pattern.
Don't peek. Think first, then click to reveal each answer.
1. Go's idiomatic way to return an error from a function is:
panic(err)throw err(result, error) as multiple return valuesLastError variable(result, error) pair. Caller checks if err != nil before using result.
2. To wrap an error with additional context (preserving the chain):
fmt.Errorf("loading: %v", err)fmt.Errorf("loading: %w", err)err.Wrap("loading")errors.Wrap(err, "loading") (third-party)%w (added in Go 1.13) creates a wrapped error that errors.Is/errors.As can unwrap. %v formats the error string but loses the chain.
3. errors.Is(err, io.EOF) checks if:
err == io.EOF exactlyerr or any wrapped error in the chain is io.EOFerr is convertible to io.EOFerr's message contains "EOF"errors.Is walks the wrap chain via Unwrap() and returns true if any error in the chain matches. This is why %w wrapping matters.
4. Using panic for normal error handling is:
panic is for "this should never happen" situations (truly broken state). Normal errors (file not found, network timeout) should be returned as error values.
5. recover() is most useful for:
recover catches panics from deferred functions in the same goroutine. The main use case: an HTTP server's middleware that recovers panics from individual handlers so the server keeps serving other requests.