Go's os.Exit Skips Every Deferred Function: Why Your Cleanup Never Runs
A small CLI tool acquires a lock file on startup and removes it with a defer before returning. It works fine in every test. In production, it occasionally leaves the lock file behind, and the next invocation refuses to start because it thinks another instance is already running. The bug isn't in the locking logic. It's a log.Fatal three call frames away, buried in a function that has never heard of the lock file and never needs to.
This is one of those Go behaviours that's documented in a single sentence and then routinely ignored until it costs someone a debugging afternoon. The sentence, from the standard library docs for os.Exit, reads: "The program terminates immediately; deferred functions are not run." Not "deferred functions in the current function". Not "unless you're near the top of main". Every deferred function, in every goroutine, anywhere in the process.
What actually happens when you call it
os.Exit is a thin wrapper around the operating system's process-termination call (exit_group on Linux). It doesn't ask the Go runtime to wind down gracefully first. There's no stack unwinding, because there's no stack traversal at all: the process simply stops existing. Deferred functions run as a side effect of a Go function returning (or panicking) and the runtime walking back up through the call stack. os.Exit never gives it that chance.
Compare that to what actually does run your defers, using three near-identical functions:
package main
import (
"fmt"
"os"
)
func withReturn() {
defer fmt.Println("deferred: withReturn")
fmt.Println("returning normally")
}
func withPanic() {
defer fmt.Println("deferred: withPanic")
defer func() { recover() }()
panic("boom")
}
func withExit() {
defer fmt.Println("deferred: withExit")
fmt.Println("calling os.Exit")
os.Exit(0)
}
func main() {
withReturn()
withPanic()
withExit()
}
Run it and you get:
returning normally
deferred: withReturn
deferred: withPanic
calling os.Exit
The normal return runs its defer. The panic, even though it's more violent than a plain return, still unwinds the stack on its way up and runs the deferred call before the recover catches it. Only withExit's defer is silently dropped, along with anything registered anywhere else in the program that hadn't already fired. If you'd registered a defer in main itself to flush a log buffer or release a database connection, it's gone too.
Where this actually bites you
The failure mode is never "I called os.Exit and forgot defer doesn't run". It's always indirect. The usual suspects:
log.Fatalandlog.Fatalf, which arePrintfollowed byos.Exit(1). They read like slightly more dramatic logging calls. They are not. Anyone who callslog.Fatalfrom inside a helper function is unilaterally deciding to skip cleanup for every caller above them, whether or not they know what those callers were holding open.- Command-line flag and CLI frameworks (the standard
flagpackage with its defaultExitOnErrormode, and most third-party CLI libraries) callos.Exiton a parse error or on--help. If flag parsing happens after you've already opened resources, those resources leak on a bad flag. - Signal handlers that call
os.Exitdirectly rather than triggering a graceful shutdown sequence. The handler runs in its own goroutine; it has no idea what defers are pending in the goroutines it's about to erase. TestMain, which is easy to get subtly wrong:
func TestMain(m *testing.M) {
setup()
defer teardown() // never runs
os.Exit(m.Run())
}
The defer there looks correct and compiles without complaint. It never executes, because os.Exit is called on the same line that would have triggered it. The fix is to call teardown() explicitly, on its own line, before the exit:
func TestMain(m *testing.M) {
setup()
code := m.Run()
teardown()
os.Exit(code)
}
It's worth being precise about what's actually lost in each of these cases, because it isn't everything. The operating system reclaims file descriptors, memory and sockets when a process dies, exit or no exit; you won't leak an open file handle at the OS level just because os.Exit ran. What you lose is application-level cleanup that the kernel can't do for you: flushing a buffered writer so the last few kilobytes actually reach disk, releasing an advisory lock file that some other process is polling for, committing or rolling back a transaction, sending a "goodbye" message to a peer, removing a temporary directory. None of that happens automatically, and none of it is the kernel's job.
The fix is to make os.Exit boring
The reliable pattern is to never call os.Exit anywhere except a single line at the very top of main, after every other function has already returned normally and every defer in the call graph has had its chance to run:
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
lock, err := acquireLock(lockPath)
if err != nil {
return fmt.Errorf("acquire lock: %w", err)
}
defer lock.Release()
f, err := os.Open(dataPath)
if err != nil {
return fmt.Errorf("open data file: %w", err)
}
defer f.Close()
return process(f)
}
Errors travel up the call stack as ordinary error values instead of triggering an immediate exit wherever they're first noticed. By the time run() returns, its defers, and every defer registered by anything it called, have already fired in the normal course of returning. os.Exit only has to decide the process's final exit code; it never has cleanup riding on it, because there isn't any left to do.
The corollary is to treat log.Fatal as a smell in anything other than main itself, or in a genuinely unrecoverable startup failure (a missing required config file before any resources have been opened, say). If you find yourself reaching for it three functions deep in business logic, what you actually want is to return an error and let the caller decide.
For cleanup that has to happen across goroutines, such as when a signal arrives and you want a graceful shutdown, don't rely on defer at all: it only fires within the goroutine whose function is returning or panicking, not because some other goroutine decided to end the process. Use a cancellable context.Context plus an explicit shutdown function that each component calls in sequence, and only call os.Exit once that sequence has completed. Defer is a stack-unwinding mechanism, not a process-lifecycle hook, and the two stop lining up the moment something in your program short-circuits the stack.