skip to content

Why does Go compile a call whose returned error you never check?

level: juniorimportance: must knowfreq 72%

answer

  1. the compiler polices variables, not returns
  2. a call is a statement all by itself
  3. bind the result and the rule bites
  4. blank identifier documents a decision
  5. vet stays silent on unchecked errors

basics

~20 s

Go's compiler rejects unused variables and unused imports, but not unused return values. A call like f.Close() is a complete statement on its own, so nothing forces you to look at the error it returns.

solid answer

~40 s

Go only polices unused *variables* and *imports*. A function call is a legal statement by itself, so `f.Close()` compiles even though `Close` returns an error, and `_, _ = w.Write(b)` compiles for the same reason. The moment you bind the result you are back under the rule: `err := f.Close()` with no later use of `err` fails to compile with "declared and not used", which is why people write `_ = f.Close()` instead. The two forms behave identically at runtime; the difference is intent. `_ =` tells a reviewer the discard was a decision, and pairs naturally with a one-line comment saying why. `go vet` will not flag either form, so unchecked errors are caught by review and by lint tooling, not by the build.

code

go · 9 lines
go
f, err := os.Create("out.csv")
if err != nil {
	return err
}

f.Close()     // legal: a call is a statement, result discarded
_ = f.Close() // legal: discard written down on purpose

cerr := f.Close() // compile error: cerr declared and not used

go deeper

for a junior

Be ready to state the rule crisply: unused variables and imports are compile errors, unused return values are not. Know that _ = f.Close() and f.Close() behave the same and differ only in what they tell a reader.

for a middle

Explain why the compiler cannot help here — error is an ordinary interface type, and a call is a legal statement — and what fills the gap: review, lint tooling, and the blank-identifier convention with a comment.

for a senior

Show where you draw the line in a real codebase: which discards you sign off on, which you block, and how you keep the write path free of them so a failed run cannot exit zero.

for a principal

Own the position that error discipline is a review and tooling investment rather than a language guarantee, and be able to justify what your CI enforces versus what you leave to judgment.

## The language rule Go has two famous "unused" compile errors, and neither of them covers a return value. - An **unused local variable** is a compile error: `declared and not used`. - An **unused import** is a compile error. - An **unused return value** is perfectly legal. That last one is not an oversight. A function call is an *expression statement* in Go's grammar: writing `f.Close()` on a line by itself is as valid as writing `doWork()`. The compiler has no idea that `error` is special — it is an ordinary interface type in the standard library, not a language construct with rules attached. So this compiles: ```go f.Close() // error return discarded _ = f.Close() // error return discarded, deliberately _, _ = w.Write(b) // n and err both discarded ``` and this does not: ```go err := f.Close() // declared and not used ``` Multi-value returns are where the blank identifier becomes mandatory rather than stylistic. You cannot assign two results to one variable, so `w.Write(b)` either gets consumed or gets blanked. ## Why reviewers care about which form you used Both forms produce the same machine code and the same silence at runtime. What differs is what the next reader can conclude. - `f.Close()` reads as *nobody thought about it*. It is indistinguishable from a forgotten check. - `_ = f.Close()` reads as *somebody decided*. A reviewer can then ask the only interesting question: was the decision right? That is why the idiom in review-heavy codebases is to write the blank assignment with a short comment — `_ = f.Close() // read-only, nothing buffered` — rather than to leave a bare call and argue about it in every pull request. ## What the toolchain does and does not do for you `go vet` does **not** report unchecked error returns. Its `unusedresult` check covers only a small fixed list of pure functions whose results are pointless to throw away — things like `fmt.Sprintf` and `errors.New`, where discarding the value means the call did nothing at all. Everything else, including every `Close`, `Flush` and `Write`, is outside vet's remit and is left to lint tooling and to humans. The practical consequence for a team: a build that is green tells you nothing about whether errors are handled. Only review, or a linter you have deliberately added to CI, does. ## When discarding really is fine The anti-pattern is not "an error was discarded", it is "an error was discarded and nobody can tell whether that was intended". Defensible discards share three properties: no data is at risk, no caller decision depends on the result, and no operator would want to be told. Typical examples: - Closing a file you opened only for **reading**. Nothing was buffered on your side; a failure to close costs you a descriptor at most. - `fmt.Println` to standard output in a short-lived command-line tool. - Removing a scratch file during cleanup, where a failure changes nothing you would act on. Write those as `_ = x()` with a comment and they will survive review. ## When it is a bug Invert the three properties and you have the write path. A nightly job that reads CSV exports and writes a rolled-up file has data at risk on every single call: the record write, the flush of whatever writer is wrapping the file, and the close of the file itself. Each returned error you drop there is a class of failure that turns into *fewer rows in the output and an exit code of zero* — the worst possible pairing, because every downstream consumer trusts the file. ## The interview point The question is testing whether you know that Go's error handling is a **convention enforced by people**, not a rule enforced by the compiler. Candidates who say "Go forces you to handle errors" have confused the visible `if err != nil` style with a guarantee that does not exist. What Go actually gives you is that the error is *there*, in the return list, impossible to overlook by accident when reading — and impossible for the compiler to make you read.

  • When is discarding a returned error in Go genuinely defensible?
    When no data is at risk, no caller decision depends on it, and no operator would want to know. Closing a file you only read from, printing to stdout in a small command-line tool, or removing a scratch file during cleanup all qualify. Write them as `_ = x()` with a one-line comment so the next reader sees a decision instead of an omission.
  • Does `go vet` fail a build on an unchecked error return?
    No. Vet's `unusedresult` check covers a small fixed list of pure functions — results of things like `fmt.Sprintf` and `errors.New` — where discarding the value means the call accomplished nothing. Unchecked `Close`, `Flush` and `Write` errors are outside its remit; catching those is a job for lint tooling and code review.
  • Why does `err := f.Close()` fail to compile when `f.Close()` alone does not?
    Because the two hit different rules. `f.Close()` is an expression statement, and Go places no constraint on a discarded return value. `err := f.Close()` declares a local variable, and an unused local variable is a hard compile error — "declared and not used". Assigning to the blank identifier sidesteps the declaration entirely, which is exactly why `_ = f.Close()` is the idiom.

saying these in an interview costs you the question

  • Claims the Go compiler forces you to handle every error
  • Believes go vet fails the build on an unchecked error return
  • Thinks `_ =` changes runtime behaviour rather than recording intent
  • Says an unused error variable compiles fine in Go
  • Discards errors across the write path and calls it idiomatic brevity