skip to content

When is ignoring the error from a deferred f.Close() on a file you only read acceptable?

level: middleimportance: nice to knowfreq 33%

answer

  1. ask what is still pending at close
  2. nothing of yours is buffered on a read
  3. the descriptor still has to be released
  4. check the reader's error, not the close

basics

~20 s

On a handle opened only for reading it is fine: no unwritten data is at stake, so the close error tells you nothing you can act on. The close itself is still mandatory, because skipping it holds a descriptor open.

solid answer

~50 s

The write path and the read path have different stakes. On a file opened with `os.Open`, nothing of yours is buffered waiting to be written, so a close error reports a condition you can neither fix nor report usefully — the read either produced the bytes or already returned an error. What is not optional is calling close at all: an open descriptor is a process-wide resource, and long-lived services run out of them. Read errors surface from the reader you are using, not from the close, so that is where the check belongs. To make the discard visible rather than accidental, many teams write `defer func() { _ = f.Close() }()` on read paths, which reads as a decision instead of an oversight and keeps a reviewer's attention on the write paths, where an unchecked close is a genuine defect.

code

go · 11 lines
go
f, err := os.Open(path)
if err != nil {
	return err
}
defer func() { _ = f.Close() }() // read-only: nothing pending to lose

sc := bufio.NewScanner(f)
for sc.Scan() {
	handle(sc.Bytes())
}
return sc.Err()

go deeper

for a junior

Remember that closing is always required, even when its error is not worth checking, and that read failures come from the reading call rather than from closing.

for a middle

Be able to justify the asymmetry: what is pending at close time is the whole argument, and descriptor exhaustion is the cost of skipping the call.

for a senior

Demonstrate that you can tell the two paths apart in review at a glance, and explain why writing the discard explicitly makes an automated sweep for unchecked cleanup errors usable.

for a principal

Set the convention so the signal survives: if every close is checked, none of them mean anything, and the write-path defects stop standing out in review.

### Two different stakes behind the same call The reason `defer f.Close()` is a bug on one path and idiomatic on the other has nothing to do with the syntax and everything to do with what is pending. On a handle you wrote to, bytes may still be in flight, and the close is where the filesystem gets its last chance to tell you they did not land. On a handle you opened with `os.Open` for reading, nothing of yours is pending: the kernel may drop a read-ahead cache, but that costs you nothing. ### What a read-path close error would even mean It is usually an already-closed descriptor (closing twice returns an error wrapping `os.ErrClosed`) or a device-level failure on an exotic filesystem. Neither changes what your function should do: the data you needed either arrived, in which case you are done, or a read already failed and returned an error with real context. Propagating the close error would replace a useful message with a useless one. ### Closing is still mandatory Dropping the error is not the same as dropping the call. Each open file consumes a descriptor, a per-process limit that a long-running service will exhaust — the classic symptom is a daemon that runs for hours and then fails every open. Go's runtime does attach a cleanup to `*os.File` that closes the descriptor once the value becomes unreachable, but that fires whenever the collector gets there, which is not a schedule you can design around. Treat it as a backstop that hides bugs in tests, not as resource management. ### Where read errors actually surface On a read path, the check that matters belongs to the reader: - `bufio.Scanner` stops the loop and reports through `Err`. - `io.Copy` returns the error directly. - `encoding/json.Decoder` and `encoding/csv.Reader` return theirs from `Decode` and `Read`. - `database/sql.Rows` reports through `Err` after the iteration, while its own close error is conventionally discarded for the same reason as a file's. A reviewer who sees a read loop with no error check is looking at a real defect; one who sees an unchecked close on that same loop is not. ### Making the decision visible Because the two paths look identical, the useful convention is to write the discard explicitly: ```go defer func() { _ = f.Close() }() ``` The blank assignment says a human decided this error does not matter here. It also survives an automated pass over the codebase for unchecked cleanup errors: the write paths light up and the deliberately-ignored read paths do not, which is what makes such a pass worth running at all. ### The cases that look like a read path but are not - A handle opened for both reading and writing, or a temporary file that is read back after being written — the write rules apply. - A wrapper that has its own `Close` doing real work, such as a `compress/gzip.Writer`; a decompressing reader's close is harmless, but the writing direction never is. - A handle you close early to release it before the function ends, and also defer as a safety net. The second close returns an already-closed error, so the deferred one must be the ignored one and the explicit one must be checked. ### The short version for an interview Ask what is pending at close time. Bytes pending means check the error. Nothing pending means close it, ignore the result, and put your attention on the reader's own error instead.

  • If the close error does not matter, why not skip the close entirely?
    Because the descriptor is a limited per-process resource. A long-running service that opens files without closing them eventually fails every open. The runtime does close the descriptor once the file value becomes unreachable, but that happens on the collector's schedule, so it hides the bug in short tests and does not prevent exhaustion under load.
  • How would you review a file opened for both reading and writing?
    Treat it as a write path. The question is not which call you used to open it but whether anything of yours is pending when the close runs. If the function wrote at any point, the close error can be the only report that those bytes were lost, so it has to be checked or captured into the returned error.

saying these in an interview costs you the question

  • skipping Close entirely because its error is uninteresting
  • relying on the collector to release descriptors
  • checking the close error but never the reader's error
  • treating a read-write handle as a read path