Fix

"file already closed" in Go

Fix the 'file already closed' error in Go by ensuring all file operations occur before calling Close().

The bridge burns when you call Close

You open a file. You read a header. You decide the data is invalid and call Close(). The program continues. A few lines later, you try to read the rest of the file. Go stops execution with file already closed.

This error is not a bug. It is a guardrail. A file handle is a bridge to an operating system resource. Close() burns the bridge. Once the bridge is down, any traffic trying to cross gets dropped. Go enforces this strictly. If you invoke Read, Write, or Stat on a closed handle, the method returns an error immediately. This prevents silent data corruption and ensures you fix the lifecycle order.

The error usually means one of two things. You called Close() too early, or you transferred ownership of the file to a caller but kept a cleanup hook that fired before the caller finished. The fix is always the same: ensure every read and write happens before the close, and make sure the entity that closes the file is the entity that owns the file.

The deferred return trap

The most common source of this error is a function that opens a file, defers the close, and returns the file handle. The code looks safe. defer handles cleanup automatically. But defer runs when the function returns. If you return the file, the function returns, defer fires, and the file closes before the caller can use it.

// OpenFile returns a file handle for reading.
func OpenFile(path string) *os.File {
    f, err := os.Open(path)
    if err != nil {
        log.Fatal(err)
    }
    // BUG: This registers Close to run when OpenFile returns.
    // The file will be closed before the caller gets it.
    defer f.Close()
    return f
}

func main() {
    f := OpenFile("data.txt")
    // OpenFile has returned. defer f.Close() has already run.
    // f is closed.
    _, err := f.Read(make([]byte, 10))
    // err is "file already closed"
    if err != nil {
        log.Fatal(err)
    }
}

The compiler cannot catch this. The types are correct. *os.File is returned. The runtime error appears only when you try to use the file. The fix is to remove the defer. If you return a resource, you transfer ownership. The caller must close it. Or, better yet, the function should read the data and return the data, not the file. Transferring file handles across function boundaries creates lifecycle bugs.

Close is terminal. Treat the file handle as dead after the call.

Walkthrough: Scope, ownership, and execution order

Go's defer statement schedules a function call to run when the surrounding function returns. It does not schedule the call to run when the variable goes out of scope. It runs at the end of the function. This distinction matters when you return resources.

In the example above, OpenFile executes. It opens the file. It registers f.Close() to run at the end of OpenFile. It returns f. The return triggers the deferred calls. f.Close() runs. The file descriptor is released to the OS. The caller receives f. The caller attempts to read. The read fails because the descriptor is invalid.

The error message is plain text. The runtime returns an error with the text file already closed. You can check this error against os.ErrClosed if you need to handle it programmatically. The compiler rejects code that tries to use a variable after it is definitely closed only in very specific static analysis cases, but generally, this is a runtime check. The compiler trusts you to manage the order.

A convention aside: defer swallows errors. When you write defer f.Close(), the return value of Close is discarded. If Close fails, the error is lost. This is accepted boilerplate in Go because close errors are rare and often indicate a deeper issue, but if you need to capture close errors, you cannot use defer directly. You must call Close manually and check the error, or use a helper function that captures the error.

Defer cleanup for local resources. Transfer ownership, transfer the close.

Realistic scenario: Concurrent access and races

The error also appears in concurrent code when one goroutine closes a file while another is still using it. This is a data race. The close operation invalidates the handle. The read operation sees the invalid handle and returns the error. The timing is non-deterministic. The error might appear on the first run, or it might appear after thousands of runs.

func processWithGoroutines(path string) {
    f, err := os.Open(path)
    if err != nil {
        log.Fatal(err)
    }

    // Goroutine 1: Reads data.
    go func() {
        data, err := io.ReadAll(f)
        if err != nil {
            // Might get "file already closed" if Goroutine 2 closes first.
            log.Printf("Read error: %v", err)
            return
        }
        log.Printf("Read %d bytes", len(data))
    }()

    // Goroutine 2: Closes the file immediately.
    go func() {
        time.Sleep(10 * time.Millisecond)
        err := f.Close()
        if err != nil {
            log.Printf("Close error: %v", err)
        }
    }()

    time.Sleep(100 * time.Millisecond) // Wait for goroutines.
}

This code is broken. Two goroutines share f. One closes it. The other reads it. There is no synchronization. The fix is to ensure only one goroutine closes the file, and that it closes only after all readers are done. Use a channel to signal completion, or use sync.WaitGroup to wait for readers before closing. Never close a shared resource without coordination.

The worst goroutine bug is the one that never logs.

Pitfalls and runtime behavior

Beyond the deferred return and concurrent close, a few patterns trigger this error.

Closing in a loop and reusing the variable. If you open a file, close it, and then try to use the variable again in the next iteration without reopening, you hit the error. The variable still holds the pointer to the closed file. You must reopen the file or use a new variable.

var f *os.File
for i := 0; i < 2; i++ {
    f, err := os.Open("data.txt")
    if err != nil {
        log.Fatal(err)
    }
    // Read data...
    f.Close()
}
// f is closed here.
_, err := f.Read(make([]byte, 10))
// Error: file already closed.

Double closing. Calling Close twice on the same file is usually safe. The second call returns an error, but it does not panic. The error text is file already closed. This is idempotent behavior. It is bad practice to close twice, but it will not crash your program. The compiler does not warn about double closes. You must manage the logic to ensure Close runs once.

Wrappers and interfaces. If you wrap a file in a struct or pass it through an interface, the error propagates. io.Reader and io.Writer do not expose close state. If you wrap a closed file in a bufio.Reader, the buffer might still have data, but subsequent reads will fail with file already closed. The error bubbles up from the underlying file. Always check errors at the source.

The error is a gift. It catches the bug before data corruption happens.

Decision: Managing resource lifecycles

Choosing how to close a file depends on ownership and scope. Use the right pattern for the situation.

Use defer f.Close() when the file is local to the function and you never return the file handle to the caller. This ensures the file closes when the function exits, regardless of how many return paths exist.

Use manual f.Close() when you need to close the file before the function returns, such as in a loop where you reuse a variable, or when you must check the error returned by Close.

Use sync.Once to wrap Close when multiple goroutines might try to close the same file, ensuring the close operation executes exactly once without races.

Return the data, not the file, when the function's job is to extract information. Transferring file handles across boundaries creates lifecycle bugs. Read the data into a byte slice or string and return that.

Use a WaitGroup or channel to coordinate closing when multiple goroutines read from the same file. Close the file only after all readers signal completion.

Goroutines are cheap. Channels are not magic.

Where to go next