skip to content

Errors Instead of Exceptions

Go makes failure an ordinary return value, so every call site either handles it or visibly ignores it. Interviewers want the defence of that verbosity, not a complaint about it.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

4

Why does os.Open return (*os.File, error) instead of throwing an exception when the file is missing?

level: juniorimportance: must knowfreq 88%

answer

  1. failure is data, not control flow
  2. the signature admits it can fail
  3. no invisible jump up the stack
  4. one interface, one method

basics

~20 s

In Go a failure is an ordinary return value of the predeclared error interface type. os.Open hands back a file and an error together, so the caller checks it on the spot and every failure path stays visible.

solid answer

~40 s

Go has no `throw` or `catch`, so a function that can fail returns the failure as data. `error` is a predeclared interface with a single method, `Error() string`, and Go's multiple return values let `os.Open` give you a `*os.File` and an `error` in one call. The caller writes `if err != nil { return nil, err }` immediately, which is why the signature alone tells you a call can fail and why the failure path is right next to the happy path instead of somewhere up the stack. Because an error is just a value you can also store it in a struct, send it on a channel, or return it from a helper. The cost is real: the three-line check repeats everywhere, and nothing in the language forces you to write it.

code

go · 8 lines
go
func load(path string) ([]byte, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	return io.ReadAll(f)
}

go deeper

for a junior

Be ready to write the four-line call-and-check by hand and to say what error is: a predeclared interface with one method, Error() string. Know that the error result comes last and is checked before any other result is used.

for a middle

Explain why multiple return values make this shape workable, and be able to argue both sides: visible failure paths and local recovery against repetition and no compiler enforcement.

for a senior

Show judgment about what deserves to be an error return at all. Interviewers listen for whether you distinguish a transport failure from a business outcome such as a 500 status or an empty result set.

for a principal

Own the consistency argument across a codebase: teams arriving from exception languages tend to invent their own signalling, and the cost lands on every caller. Be able to justify holding the line on returned errors as a house rule.

## The design choice Most mainstream languages report failure by throwing: the failing call abandons the current statement, the runtime unwinds the call stack running cleanup as it goes, and control resumes at whichever enclosing handler claims the type. Go does none of that for ordinary failures. A Go function that can fail simply *returns* the failure, alongside whatever it managed to produce. `os.Open(name string) (*os.File, error)` is the canonical shape. On success you get an open file and a nil error. On a missing file you get a nil `*os.File` and a non-nil error describing what went wrong. ## What `error` actually is `error` is not a keyword and not a struct. It is a predeclared interface type with exactly one method: - a value satisfies `error` if it has an `Error() string` method. That is the whole contract. It means a failure in Go is an ordinary interface value: you can assign it to a variable, put it in a struct field, send it over a channel, keep a slice of them, return it up three layers untouched, or compare it against another value. None of that is available for a thrown exception, which exists only while it is in flight. ## Why multiple return values matter Go's ability to return more than one value is what makes this practical. Without it, reporting both a result and a failure needs an out-parameter, a wrapper type, or a sentinel result. With it, the idiomatic signature is `(T, error)` and the idiomatic call site is: ```go f, err := os.Open(path) if err != nil { return nil, err } ``` The error is conventionally the last result, and the caller is expected to test it before touching anything else. ## What the visible chain buys you 1. **The signature is the documentation.** You can tell from `func load(path string) ([]byte, error)` that this can fail. In an exception language you must read the body, or the callees, or trust a comment. 2. **Control flow is local.** There is no invisible jump. Reading top to bottom, every place the function can leave early is on screen, marked by a `return`. 3. **Recovery decisions happen where the context lives.** The function that opened the file knows whether a missing file means "create it", "skip this input" or "abort"; a handler ten frames up knows only that something threw. 4. **Failures compose like data.** A worker can return its error on a channel; a batch job can collect a slice of them; a struct can cache the first failure it saw. ## What it costs The criticism is fair and you should be able to state it. The `if err != nil` block is three lines of ceremony repeated at nearly every call, it dilutes the interesting logic, and — unlike a checked exception — the compiler will not make you write it. A caller can ignore the error entirely and the program still builds. Go trades compile-time enforcement for visibility, and leans on review and linters in CI to catch what the compiler won't. ## Where panic fits Go does have `panic` and `recover`, and they do unwind. They are not the mechanism for routine, expected failure such as a missing file, a bad port number or a refused connection — those are returned. Reaching for a panic where an error return belongs is the single most common instinct of an engineer arriving from an exception-based language, and it is the first thing a reviewer will push back on. ## Two confusions worth clearing up - **A non-nil error does not stop your function.** Nothing unwinds for you. If you check the error and forget the `return`, execution continues into code that assumes success. - **Not every unhappy outcome is an error return.** `http.Get` returns a nil error for an HTTP 500 response, because the exchange itself succeeded; the status code is data in the response, not a transport failure. Deciding what is worth returning as an `error` is part of designing the function.

  • Does http.Get return a non-nil error when the server replies with HTTP 500?
    No. `http.Get` returns an error only when it could not complete the exchange at all — DNS failure, connection refused, a timeout, too many redirects. A 500 is a perfectly successful HTTP exchange, so `err` is nil and you must inspect `resp.StatusCode` yourself. Treating a nil error as "the request succeeded" is a common porting bug.
  • If errors are ordinary values, what can you do with one that you cannot do with a thrown exception?
    Keep it. You can store an error in a struct field, send it on a channel from a goroutine, collect a slice of failures from a batch and report them together, hand it to a function as an argument, or compare it with another value. An exception exists only while it is propagating; a Go error is a value with a normal lifetime.
  • What is the honest cost of this design?
    Verbosity and no enforcement. The `if err != nil { return ... }` block repeats at nearly every call site and pushes the interesting logic down the page, and because the error is just a return value the compiler will happily build code that never looks at it. Go accepts that trade in exchange for making every failure path visible where it happens.

A returned error is a receipt handed back with the goods, which you read before using them; a thrown exception is a fire alarm that empties the building and lets whoever is nearest the exit decide what happened.

saying these in an interview costs you the question

  • Calls panic and recover Go's normal try/catch
  • Thinks error is a keyword rather than an interface type
  • Believes a non-nil error stops the caller automatically
  • Assumes http.Get returns an error for a 500 response
  • Says the compiler forces you to check every returned error
open as a page

When a Go function returns a non-nil error, what may the caller assume about its other return values?

level: middleimportance: must knowfreq 68%

basics

~20 s

Nothing useful, unless the documentation says otherwise. By convention the other results are their zero values on failure, so a returned pointer is nil. Using one before returning is the classic nil dereference panic in Go code.

open as a page

Why does Go's compiler reject an unused local variable but accept an unchecked returned error?

level: middleimportance: should knowfreq 44%

basics

~20 s

An error is an ordinary return value, not a contract the compiler tracks. Go rejects unused variables and imports as build hygiene, but it deliberately has no checked exceptions and no must-use rule, so discarding a returned error compiles cleanly.

open as a page

A Go proxy's io.Copy to the client fails mid-body after http.ResponseWriter.WriteHeader — what does the returned error let you do that stack unwinding would not?

level: seniorimportance: nice to knowfreq 33%

basics

~20 s

It lets the hop that failed act while it still has the context: stop the copy, close the upstream, and record how many bytes io.Copy relayed. Unwinding to one handler loses where the failure happened.

open as a page