Skip to content
gohacks.dev
Basics

Errors From Deferred Close Calls in Go and What to Do

Check the error from a deferred Close in Go when you write: why it matters, the named-result pattern, errors.Join, Flush and Sync, and when to ignore it.

7 min read
Summarize in AI
Flat illustration of a log with a failing line and a wrench.

Every Go tutorial on files shows the same pair of lines: open the file, defer f.Close(). It is the right habit, because it guarantees the file is closed on every path out of the function. It also silently throws away a value, and for a file you have written to, that value can matter. This article covers when to care, how to keep the error without making the function a mess, and the related mistakes around buffered writers.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

It belongs to the error handling guide and builds on named return values and defer, which is the mechanism the fix relies on. Everything ran on Go 1.25.6.

Why Close can fail

A call to Write on a file usually succeeds when the operating system has accepted the bytes into its buffer cache, not when they are on disk. The data is written out later. If that later step fails, the disk is full, a network file system drops, a quota is exceeded, the failure has to be reported somewhere, and the last place it can be reported is the call that closes the file. POSIX says as much: close can return an error from a delayed write. So a program that writes a file, ignores Close, and exits with status 0 may have lost data and told nobody.

For a file opened read-only, Close has nothing left to flush, and its error is of no practical use. The usual advice, ignore it, is correct there. The discipline is to tell the two cases apart and treat the write case with care.

The naive version, and what it hides

main.goGo
package main

import (
	"errors"
	"fmt"
	"io"
)

// flaky stands in for a file whose Close reports a delayed write failure.
type flaky struct{}

func (flaky) Write(p []byte) (int, error) { return len(p), nil }
func (flaky) Close() error                { return errors.New("close: no space left on device") }

func save(w io.WriteCloser, data string) error {
	defer w.Close()
	_, err := io.WriteString(w, data)
	return err
}

func main() {
	err := save(flaky{}, "important")
	fmt.Println("save returned:", err)
}
Text
save returned: <nil>

save returns nil. The write was accepted, the close failed, and the caller is told everything worked. The type here is a small stand-in so the failure is reproducible on any machine without filling a disk, but a real full disk or a network mount behaves the same way.

The fix: capture it with a named result

A deferred function can assign to the named result. The shape that keeps the first error when there is one, and otherwise reports the Close error, is:

main.goGo
package main

import (
	"errors"
	"fmt"
	"io"
)

type flaky struct{}

func (flaky) Write(p []byte) (int, error) { return len(p), nil }
func (flaky) Close() error                { return errors.New("close: no space left on device") }

func save(w io.WriteCloser, data string) (err error) {
	defer func() {
		if cerr := w.Close(); err == nil {
			err = cerr
		}
	}()
	_, err = io.WriteString(w, data)
	return err
}

func main() {
	fmt.Println("save returned:", save(flaky{}, "important"))
}
Text
save returned: close: no space left on device

Now the caller sees the failure. The if cerr := w.Close(); err == nil form always closes, and only overwrites err when the function had not already failed. That ordering is deliberate: if the write failed, that is the root cause, and a Close error after it is usually a consequence and not news. The result has to be named (err error in the signature) so the deferred closure can reach it. The closure runs after the return statement has set the result, which is why assigning inside it changes what the caller receives (named return values and defer explains that order).

Keeping both with errors.Join

Sometimes both errors matter. Since Go 1.20, errors.Join keeps them together, and errors.Is still finds either:

main.goGo
package main

import (
	"errors"
	"fmt"
	"io"
)

type broken struct{}

func (broken) Write([]byte) (int, error) { return 0, errors.New("write: input/output error") }
func (broken) Close() error              { return errors.New("close: input/output error") }

func save(w io.WriteCloser, data string) (err error) {
	defer func() { err = errors.Join(err, w.Close()) }()
	_, err = io.WriteString(w, data)
	return err
}

func main() {
	fmt.Println(save(broken{}, "x"))
}
Text
write: input/output error
close: input/output error

If both fail you get both lines. If only the close fails, Join(nil, cerr) is the close error alone. If neither fails, it is nil. The expression is one line, and it reads more clearly than the conditional form when you want everything on record (errors.Join). Use the conditional form when the close error after a write failure would only be noise.

Buffered writers: Flush first

A bufio.Writer holds bytes in memory until it fills or you call Flush. Closing the file under it does not flush it, and the buffered tail is lost. The writer's Flush returns an error you must check, and it has to happen before the close:

main.goGo
package main

import (
	"bufio"
	"fmt"
	"os"
	"path/filepath"
)

func writeLines(path string, lines []string) (err error) {
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer func() {
		if cerr := f.Close(); err == nil {
			err = cerr
		}
	}()

	w := bufio.NewWriter(f)
	for _, l := range lines {
		if _, err := fmt.Fprintln(w, l); err != nil {
			return err
		}
	}
	return w.Flush()
}

func main() {
	path := filepath.Join(os.TempDir(), "lines.txt")
	defer os.Remove(path)

	fmt.Println(writeLines(path, []string{"one", "two", "three"}))
	data, _ := os.ReadFile(path)
	fmt.Printf("%q\n", data)
}
Text
<nil>
"one\ntwo\nthree\n"

Order matters: Flush returns its error through the function's result, and the deferred Close runs afterwards. Without the Flush line the file would be created and the output would be empty, because the three short lines never filled the 4 KB buffer. This is a frequent bug, and it hides well in tests that write to a temporary file and read it back after the writer has been closed by the test, not by the code.

Sync, for durability

Close reports errors the OS knows about, but it does not wait for data to reach stable storage. If power loss would be a problem, as for a database or a file replacing a config, call f.Sync() before closing and check its error. Sync is slow, since it waits for the disk, so reserve it for data you cannot afford to lose. The temp-file-and-rename pattern from the todo app pairs Sync with Rename for exactly this reason.

Closing twice

The mistake in the other direction is closing too often.

Calling Close a second time on an os.File returns an error, os.ErrClosed. That matters when you close explicitly on the success path and also defer a close for the early returns:

main.goGo
package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
)

func main() {
	f, err := os.CreateTemp("", "x")
	if err != nil {
		panic(err)
	}
	defer os.Remove(f.Name())

	fmt.Println(f.Close())
	err = f.Close()
	var pe *fs.PathError
	fmt.Println(errors.Is(err, os.ErrClosed), errors.As(err, &pe) && pe.Op == "close")
}
Text
<nil>
true true

The second call fails with a *fs.PathError for the close operation that wraps os.ErrClosed, and the program prints true true, which is how you would test for it in code. If a function does both an explicit close and a deferred one, the deferred one's error is noise and should be ignored. A single deferred close, with the error captured as above, avoids the question. If you want the explicit close for a reason, such as releasing the file before a long computation, set a flag or use a small wrapper that makes the second Close a no-op, and keep the deferred one as the safety net for early returns. The simplest structure is still one open, one deferred close and a function short enough that nothing needs the file released early.

When ignoring is fine

You may ignore the Close error when the file was only read, when the error could not change what you do next, and when the file is a temporary one you are about to delete. For HTTP response bodies, the Close error of resp.Body is conventionally ignored, since the data has already been read and the connection handling is the transport's job. When you ignore it on purpose, make it visible so the next reader and the linter agree: defer func() { _ = f.Close() }() or a comment that says why (ignoring errors on purpose). Linters differ on whether a bare defer f.Close() is reported, so check what yours does and tighten it for the write paths where it matters (errcheck). For the exact guarantees, see the os.File.Close documentation and bufio.Writer.Flush.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.