skip to content

Why does a Go function that can fail return (result, error), and what must the caller do first?

level: juniorimportance: must knowfreq 85%

answer

  1. failure is a value, not a jump
  2. error goes last in the list
  3. check it before you use the rest
  4. on failure the other result is zero
  5. guard clause, then the happy path

basics

~20 s

Go reports failure as an ordinary value returned last, beside the real result. The caller tests err != nil before touching the other result, because on failure that result is only a zero value, not data.

solid answer

~50 s

A fallible Go function returns its useful value first and an `error` last: `func loadVersion(path string) (int, error)`. Failure is data, not a jump — nothing is thrown, so the call returns normally and the caller decides. The convention the whole standard library follows is that when `err != nil` the other results carry no promise; a failing function returns the zero value beside the error (`return 0, err`), and a caller that reads the int without checking is reading a zero it invented. So the idiom is a guard clause immediately after the call: `v, err := loadVersion(p); if err != nil { return 0, err }`, handling or returning the failure right there and leaving the happy path left-aligned below. A function with no useful result just returns `error` alone, and returns `nil` on success.

code

go · 16 lines
go
func loadVersion(path string) (int, error) {
	b, err := os.ReadFile(path)
	if err != nil {
		return 0, err
	}
	return strconv.Atoi(string(bytes.TrimSpace(b)))
}

func run(path string) error {
	v, err := loadVersion(path)
	if err != nil {
		return err
	}
	// v is meaningful only below this line
	return migrateTo(v)
}

go deeper

for a junior

Be ready to write the shape from memory: value first, error last, then a guard clause that returns early. Say out loud that when the error is non-nil the other result is only a zero value.

for a middle

Explain why the caller must branch on the error before reading anything else — it is the contract, not a style rule — and show the zero-value return on the failure path inside the function you write.

for a senior

Demonstrate how you hold the line in review: no result read before its error is checked, no dropped errors, early returns so the happy path stays left-aligned and every exit is scannable in a long function.

for a principal

Own the tradeoff when you shape a package's API: making a function fallible is a caller-wide edit, so decide deliberately which operations get an error result and which are documented as infallible, rather than adding one later under pressure.

## The shape In Go, a function that can fail does not signal that failure by unwinding the stack. It returns it. The convention, followed uniformly by the standard library, is that the failure value is of type `error` and comes **last** in the result list, after whatever the function actually computed: ```go func loadVersion(path string) (int, error) func (c *Client) Fetch(id string) (*Record, error) func Save(r *Record) error ``` The last form is the degenerate case: when there is nothing to hand back but success or failure, `error` is the only result. Success is `nil`; failure is any non-nil `error` value. Because the error is just a return value, the call site is ordinary code. There is no separate channel the failure travels on, no invisible edge out of the function, and nothing the caller can forget to install. What the caller *can* forget is to look at it — and that is the entire risk this convention trades for its simplicity. ## What the other results mean when err is non-nil The rule to internalise: **when `err != nil`, the results beside it carry no promise.** A failing function is expected to return the zero value for its other results — `0`, `""`, `nil`, an empty struct — and callers must not read them. This is why you see: ```go func loadVersion(path string) (int, error) { b, err := os.ReadFile(path) if err != nil { return 0, err } return strconv.Atoi(string(bytes.TrimSpace(b))) } ``` The `return 0, err` is not decoration. It is the function stating that it has nothing to say about the version number. A caller that writes `v, _ := loadVersion(p)` and then migrates the database to schema `v` is migrating to schema 0. The convention has one famous, deliberate exception — `io.Reader.Read`, which may return `n > 0` **and** a non-nil error in the same call, and whose documentation instructs callers to process those n bytes first. That exception exists because a stream can hand you data and end in the same breath. Treat it as the exception it is; everywhere else, a non-nil error means the rest is zero. ## The guard-clause idiom The canonical call site is three lines: ```go v, err := loadVersion(path) if err != nil { return err } // v is meaningful only from here down ``` Go has no `else` in this pattern by convention. Each failure is handled and the function returns early, so the successful path stays at one indentation level and a reader scanning a long function can find every exit by looking for `return` inside an `if err != nil` block. The alternative — nesting the success path inside `if err == nil { ... }` — is legal Go and is considered poor style precisely because it buries the thing you came to read. When the value is only needed inside the branch, the compact form scopes both: ```go if err := Save(rec); err != nil { return err } ``` Here `err` exists only for the duration of the `if` statement, which is usually what you want — though it is also the shape that produces the classic shadowing bug when an outer `err` of the same name exists and someone expects the assignment to reach it. ## Why the ordering matters in practice Putting `error` last is not arbitrary. It makes the destructuring read as "value, then whether the value is real", it lets `return zero, err` line up visually across a package, and it means a reader can identify a fallible function from its signature alone without reading a doc comment. A function that returned `(error, int)` would compile fine and would be rejected in review of any Go codebase. It also composes: because `strconv.Atoi` already returns `(int, error)`, the example above can hand its results straight through with a bare `return strconv.Atoi(...)`. That only works because the shapes match, which is one more reason the convention is worth following exactly rather than approximately. ## What a caller owes Three things, in order: check the error before reading anything else; decide at that point whether this function can do something about it or must hand it up; and if the answer is "hand it up", return it immediately rather than continuing with values you have just been told are meaningless.

  • What is the signature when the function has nothing to return but success or failure?
    Just `error`: `func Save(r *Record) error`. Success is a `nil` return, failure is any non-nil `error`. The same guard clause applies at the call site — `if err := Save(rec); err != nil { return err }` — and nothing else about the convention changes.
  • A function fails halfway through and has computed a partial result. What should it return?
    The zero value and the error, unless its documentation explicitly promises otherwise. Callers are entitled to ignore results that sit beside a non-nil error, so returning half-filled data invites someone to use it. If the partial result genuinely matters, say so in the doc comment — as `io.Reader.Read` does.
  • Why does the error go last rather than first?
    Convention, and it is absolute in practice. Reading `v, err := f()` gives you the value then its validity, `return zero, err` lines up identically across a package, and a fallible function is recognisable from its signature alone. `(error, int)` compiles and would still be rejected in review.

It is like a delivery slip stapled to the parcel rather than a fire alarm: the courier always hands you both, and you read the slip before you open the box.

saying these in an interview costs you the question

  • Says Go signals failure by throwing and catching
  • Reads the first result without testing the error
  • Puts the error first in the result list
  • Assumes a non-nil error still leaves usable data
  • Nests the success path inside if err == nil