When a Go function returns a non-nil error, what may the caller assume about its other return values?
answer
- assume nothing on the failure path
- zero values unless the doc says otherwise
- a returned pointer will be nil
- the check has to end the function
basics
~20 sNothing 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.
solid answer
~50 sThe convention is that the non-error results are meaningless when `err != nil`, and in practice they are the zero values — nil for a pointer, slice or map, 0 for a number, "" for a string. That is why the check must *end the function*: writing `if err != nil { log.Print(err) }` without a `return` and then touching the result dereferences a nil pointer and panics. Because Go does not unwind for you, the `return` is the only thing that stops execution. A few standard library calls deliberately document an exception — `io.Copy` returns the number of bytes it did write alongside a non-nil error, which is genuinely useful — but that is a documented contract, not the default. When you write your own function, either return zero values on failure or say in the doc comment exactly what the caller may still read.
code
go · 6 linesresp, err := http.Get(upstream)
if err != nil {
log.Print(err)
}
// resp is nil here, so resp.Body panics
io.Copy(w, resp.Body)go deeper
Remember the rule as a habit: check the error first, and make the check end the function with a return. Expect a returned pointer to be nil whenever the error is non-nil.
Explain why the convention exists and where it is documented to differ, and be able to trace exactly which expression panics in a snippet that logs an error without returning.
Show how you catch this before production: an injected-failure test case, review attention on any error block without a return, and doc comments on your own functions that state what survives a failure.
Set the expectation for the codebase: functions return zero values on failure unless their doc says otherwise, so callers never have to inspect an error to learn whether a result is readable.
## The convention Go has no way to say "this result is only valid on success" in the type system, so it says it by convention: **when the returned error is non-nil, the other results carry no meaning and are the zero value for their type.** That means: - a `*T` result is `nil`, - a slice or map result is `nil`, - a numeric result is `0`, - a string result is `""`, - a struct result is the zero struct. And because a caller reads that convention as a promise, a function you write should honour it. Returning a half-filled struct next to a non-nil error invites the caller to use it. ## The failure this prevents Go does not unwind. A non-nil error is a value sitting in a variable; execution continues to the next statement whatever that value is. So the check is not really "check the error" — it is "stop". ```go resp, err := http.Get(upstream) if err != nil { log.Print(err) // noticed, but did not stop } io.Copy(w, resp.Body) // panic: resp is nil ``` Here the failure was even *observed*, and the program still crashes: `resp` is nil, so evaluating `resp.Body` dereferences a nil pointer. This is the single most common shape of nil-dereference panic in Go, and it is a direct consequence of failures being values rather than control flow. In an exception language the throw itself would have skipped the second call; in Go only your `return` does. The same bug hides behind a deferred cleanup written too early. `defer resp.Body.Close()` placed *before* the error check panics on the spot, because the receiver expression `resp.Body` is evaluated when the `defer` statement runs, not at return. ## Documented exceptions The convention is a default, not a law, and the standard library documents where it differs. The most useful example is copying: ```go n, err := io.Copy(dst, src) ``` `n` is the number of bytes that actually reached `dst`, and it is meaningful even when `err` is non-nil. That is exactly what an operator wants after a mid-stream failure: an upstream that died on the first byte and one that died after 900 MB produce the same error text but very different `n`. The rule to take away is: **the doc comment is the contract; the zero-value convention is what you assume when the doc is silent.** If you are unsure whether a partial result is usable, read the function's documentation rather than guessing from the shape of the signature. ## Writing functions that honour it When you author a `(T, error)` function: 1. Return the zero value on every failure path. Do not return a partially constructed value "in case it is useful". 2. If a partial result genuinely helps the caller — bytes written, rows processed, items successfully parsed — return it, and say so in the doc comment in one sentence. 3. Never return a non-nil error together with a result whose validity depends on which failure happened. That forces the caller to inspect the error to know whether the value is readable, which is exactly the coupling the convention exists to avoid. ## Reviewing for it The pattern to look for in a review is an `if err != nil` block whose body does not end the function: no `return`, no `continue`, no `break`, no `os.Exit`. That block is not a check, it is a comment with side effects. Every use of the sibling result below it is a live nil dereference waiting for the first production failure, and unit tests usually never hit it because the happy path always returns a non-nil value. The second thing to look for is a result being used *between* the call and the check — an argument built from it, a length taken, a field read. Once the value has been touched, moving the check later does not help.
- Which standard library call returns a meaningful first result together with a non-nil error, and why?`io.Copy` is the clearest one: its `int64` result is the number of bytes actually written to the destination, and it stays meaningful when the copy fails part-way. It is documented that way because partial progress is the whole point of a streaming copy. Treat it as an exception you read in the docs, not as evidence that the convention is loose.
- Why does the bug survive testing so often?Because the happy path never produces the nil. Tests that exercise a successful call get a non-nil result and pass; the dereference only fires when the underlying call actually fails, which usually means a real network, a real disk or a real dependency. A table test with an injected failure case is what catches it.
- If your own function must hand back partial work with a failure, what do you owe the caller?An explicit sentence in the doc comment saying which result is still valid and what it means. Silence is read as the zero-value convention, so a caller who finds a half-populated struct next to a non-nil error has no way to know whether it was intentional. State it, or return the zero value.
saying these in an interview costs you the question
- Assumes the first result is usable when err is non-nil
- Logs the error and falls through into the result
- Thinks a non-nil error aborts the caller for you
- Returns a half-built struct alongside a failure
- Believes every stdlib call zeroes its results on failure