Skip to content
gohacks.dev
Basics

Why Go Returns Errors Instead of Throwing Exceptions

Why Go returns errors instead of throwing exceptions: the same task in Python and Go, what each model hides, and the trade-offs of explicit checks.

7 min read
Summarize in AI
Flat illustration of a log with a failing line and a wrench.

People coming to Go from Python, Java, JavaScript or C# usually have the same first reaction to if err != nil: surely there is a better way. There is a different way, and the Go designers chose it knowingly, so it is worth understanding what they traded. This article puts the two models side by side on one small task, says plainly what each costs, and gives a rule for when the difference matters. If you want the practice rather than the reasoning, the main error handling guide is the place to go.

Tested with Go 1.25.6 and Python 3.13, macOS on Apple Silicon (arm64)

The same task in both

A report averages the numbers in several groups. One group is empty, which makes the average undefined. In Python the natural code lets the exception travel:

Python
def average(xs):
    return sum(xs) / len(xs)

def report(groups):
    for name, xs in groups.items():
        print(name, average(xs))

try:
    report({"a": [1, 2, 3], "b": [], "c": [4]})
except ZeroDivisionError as e:
    print("error:", e)
Text
a 2.0
error: division by zero

The Python function has nothing about failure in it. average and report are four lines between them. When average([]) divides by zero, the exception unwinds out of both functions and lands in the handler at the bottom. Group a was printed, group b raised, and group c was never reached, a fact that the code at the top does not show and that a reader has to deduce.

The Go version states the possibility in every signature on the way:

main.goGo
package main

import (
	"errors"
	"fmt"
	"sort"
)

var errEmpty = errors.New("no values")

func average(xs []float64) (float64, error) {
	if len(xs) == 0 {
		return 0, errEmpty
	}
	var sum float64
	for _, x := range xs {
		sum += x
	}
	return sum / float64(len(xs)), nil
}

func report(groups map[string][]float64) error {
	names := make([]string, 0, len(groups))
	for name := range groups {
		names = append(names, name)
	}
	sort.Strings(names)
	for _, name := range names {
		avg, err := average(groups[name])
		if err != nil {
			return fmt.Errorf("group %s: %w", name, err)
		}
		fmt.Println(name, avg)
	}
	return nil
}

func main() {
	err := report(map[string][]float64{"a": {1, 2, 3}, "b": {}, "c": {4}})
	if err != nil {
		fmt.Println("error:", err)
	}
}
Text
a 2
error: group b: no values

It is longer, and the extra length is information. average says it can fail, report says the failure stops the loop and adds the group name, and main decides to print it. You can see that c is not reached, and that the decision to stop is a line of code a person wrote and can change. Replace the return with a continue and the report skips the bad group, which is the usual change when someone asks for "a report that doesn't die on empty groups". In the exception version that change means wrapping the body of the loop in its own try.

What exceptions are good at

Exceptions have real strengths and it is dishonest to pretend otherwise.

Less code on the happy path. Code that mostly succeeds reads as a straight line of what it does, and the handling sits in one place. For scripts and glue code that is a clear win.

A traceback for free. An uncaught Python exception prints the call stack with line numbers. A Go error is a string unless someone added more. Finding where it came from means grepping for the message or adding wrapping, and stack traces for errors covers how much it matters and what the options cost.

Failure from anywhere. A deep function can signal a problem to a distant handler without every function in between cooperating. In a language without exceptions, each of them must pass the error along.

What they cost

Invisible control flow. Any call can be a hidden exit. Reading a function to see what it does includes asking of every line "what if this raises?", and in languages with checked exceptions the answer is in the signature, but unchecked ones, which are the common case in Python, JavaScript and C#, leave it to documentation and luck. This is the objection the Go team gives in its FAQ: tying failure to a try-catch-finally control structure makes code convoluted, and it encourages programmers to treat ordinary events, such as failing to open a file, as exceptional (Go FAQ).

Cleanup as a separate mechanism. Whatever a function acquires before a call that might raise has to be released by finally, with or a destructor. These work well once learned, and are a second system beside the first. In Go, defer does the job on every path out of a function, whether by return or by panic.

Handlers that catch too much. except Exception: is the most-written line that is wrong. Because any call can raise anything, defensive code catches broadly, and broad handlers swallow the bug as well as the expected failure.

What Go's approach costs

The honest list is as long.

Boilerplate. Three lines per call, thousands of times. Language-level fixes have been proposed repeatedly and the Go team has declined them so far (the proposals explains why), and a few techniques reduce the repetition without changing the model (reducing boilerplate).

Errors that can be ignored. Nothing forces a caller to look at the result. A call to os.Remove as a bare statement compiles, and so does n, _ := strconv.Atoi(s). Linters close most of the gap (errcheck), but they are an add-on.

Context is the author's job. An exception carries its stack automatically. A Go error carries only what each layer chose to add. A codebase with discipline wraps at every boundary, and one without it logs EOF and nothing else.

Mixed signals around panic. Go does have an exception-like mechanism. panic unwinds the stack, runs deferred calls and can be stopped by recover. It exists for conditions that mean the program is wrong, and the standard library uses it internally in a few places (the encoding/json encoder, for one) while converting to an error at the package boundary. Panic or error draws that line.

Errors as data

The deeper difference is that a Go error is a value, and values can be inspected, wrapped, compared, stored, sent on channels and returned from goroutines. Exceptions are a control mechanism. You can catch one, but you cannot put it in a struct field next to the results of its siblings. A batch operation that records ten failures out of a hundred items and reports them afterwards is natural with values and awkward with exceptions. errors.Join and a plain slice of errors are all it takes (collecting multiple errors).

Because they are values, errors also compose with the rest of the type system. A function can return a typed error that carries a status code or a retry hint, and the caller asks errors.As for it (custom error types). Languages with exceptions get the same effect through exception classes, and Go gets it through ordinary types.

Which one suits what

The practical consequences cut both ways.

  • Scripts, notebooks and one-off tools: exceptions win. The failure mode is "print a traceback and stop," which is what you want and what you get for free.
  • Long-running services: explicit errors win. A server must decide what to do with each failure, retry it, report it, degrade, and the places where that decision happens should be visible in the code. The dozen functions between an I/O call and the handler that turns it into a 503 are exactly where exceptions hide the decision.
  • Libraries: the explicit signature is an API. A caller reads (T, error) and knows to handle failure without reading the docs.

Language choice rarely rests on this alone, but if you find Go's approach tiring in week two, the useful step is to notice what you gain when you return to read your own code in month six. The structure is the same everywhere, and you will not have to hold the whole call graph in your head to know where a function can leave.

Rust's Result and Swift's throws land on the same side of this divide, with syntax (? and try) that shortens the repetition while keeping the exits visible, and that is the main thing Go's critics ask for. For now, the Go answer is the explicit one, and the discipline that makes it work is covered in handling errors by wrapping, checking and logging. For the language's own account of its design, read the Go FAQ entry on exceptions and the Go blog's Errors are values.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.