skip to content

Why does os.Exit skip every deferred call in a Go program, and what is lost?

level: middleimportance: must knowfreq 60%

answer

  1. the process does not unwind, it stops
  2. defer needs a return to fire
  3. a panic is the polite version of ending
  4. whatever you parked in defer is dropped
  5. your own buffering layer, not os.Stdout

basics

~10 s

os.Exit ends the process immediately at the runtime level without unwinding any goroutine's stack, so no deferred call anywhere runs. Whatever a deferred flush, close, cleanup or summary would have done simply never happens.

solid answer

~40 s

`os.Exit` asks the operating system to terminate the process right there. It does not return, it does not unwind the calling goroutine's stack, and deferred functions only run as a stack unwinds - so none of them fire, not in the calling function, not further up, and not in any other goroutine. What you lose is whatever cleanup you parked in a `defer`: a flush of a writer you buffered yourself, a temporary file that never gets removed, a final summary line, an explicit `Close` on a file or a database handle. Bytes already handed to `os.Stdout` or `os.Stderr` are safe, because Go writes those straight to the file descriptor rather than through a buffered stream. The habit that follows is to allow `os.Exit` only in `main`, after everything below it has returned.

code

go · 9 lines
go
func migrate(apply func() error) {
	defer fmt.Println("migration summary written")

	if err := apply(); err != nil {
		log.Print(err)
		os.Exit(1) // the process ends here; the deferred line never prints
	}
	fmt.Println("all steps applied")
}

go deeper

for a junior

Remember the rule itself: os.Exit ends the process on the spot and deferred calls do not run. Keep os.Exit in main and let the functions beneath it return errors instead.

for a middle

Explain the mechanism - deferred calls fire as a stack unwinds, and os.Exit unwinds nothing - and be precise about what is lost: your own buffering layers, not bytes already written to os.Stdout.

for a senior

Show how this bites in production: a job that reports exactly the right failure status with the last lines of its own output missing. Say where you allow os.Exit at all and what guarantees cleanup instead.

for a principal

Decide the shape that makes the mistake unrepresentable - exit only at the top, cleanup owned by whoever acquired the resource - and be explicit about what you accept losing when a process is killed rather than exiting.

## Two mechanisms that do not meet `defer` registers a call on the **goroutine's** stack frame. Those registered calls run when the frame goes away: when the function returns normally, or when a panic unwinds through it. Deferred calls are a property of returning, not a property of the process shutting down. `os.Exit` is the other thing entirely. It hands the status to the operating system and the process stops. There is no return, no unwinding, no frames going away one at a time - the whole address space simply ceases to exist. Since nothing unwinds, nothing deferred runs. So the answer to "why" is not that Go chose to skip them as a policy. It is that there is no moment at which they could run: `os.Exit` is not a return. ## What is actually lost Anything whose only trigger was a deferred call: - **a flush of your own buffering layer** - if you wrapped a file or a network connection in a writer that batches, the bytes still sitting in memory are gone; - **explicit closes** - a file whose contents were flushed but whose close was deferred, a database handle, a lock file; - **cleanup** - a temporary directory removal, a state file being renamed into place; - **end-of-run work** - a summary line, a duration report, a final metrics push, an "all done" marker another system watches for. The result is a characteristic failure: the process reports a perfectly correct non-zero status, and the tail of its own output is missing. The person reading the log sees the run stop mid-sentence and assumes it was killed, when in fact it exited deliberately. ## What is not lost Writes that already reached `os.Stdout` or `os.Stderr`. In Go those are plain file descriptors written with a syscall per write - there is no C-style user-space buffer that has to be flushed at exit, which is why Go programs do not need an `atexit` flush the way C programs do. `fmt.Println` output that has already executed is on its way out regardless of how the process ends. That distinction is worth stating precisely in an interview, because "os.Exit loses your output" is only true of buffering **you** added. ## The contrast with a panic An unrecovered panic *does* unwind. It runs each deferred call in the panicking goroutine on the way up the stack, so a deferred flush registered there fires, and only then does the runtime print the panic value plus a dump of the goroutines and exit with status 2. This is why a crash can leave you with more complete output than a deliberate exit does. `log.Fatal` is not a third case: it is a print followed by `os.Exit(1)`, so it has exactly the os.Exit behaviour under a shorter name. ## Other goroutines `os.Exit` called anywhere kills the whole process, so defers in every other goroutine are skipped too - including the ones in `main` itself if the call came from a worker. A single deep call site can therefore drop cleanup that the code around it clearly expected to happen, and nothing in the type system marks that call site as special. ## How to avoid it Push the work into a function that returns an error and owns its resources with ordinary `defer`. Let `main` be the only place that turns a returned error into a status. By the time `os.Exit` executes there, every deferred call inside the work has already run and every buffer has already been flushed. If you must exit from somewhere else - a genuinely unrecoverable startup failure - do the flushing explicitly, right before the exit, and accept that you have taken over a responsibility the language was doing for you.

  • Does an unrecovered panic have the same problem as os.Exit?
    No. A panic unwinds the panicking goroutine's stack and runs each deferred call on the way up, so flushes and closes registered there do fire. Only after that does the runtime print the panic and a goroutine dump and exit with status 2. os.Exit runs nothing at all.
  • Which output survives an os.Exit and which does not?
    Bytes already written to os.Stdout or os.Stderr survive, because Go writes those directly to the file descriptor with no user-space buffer to flush. Anything still sitting inside a writer you wrapped around a file, or in a log handler that batches, is lost, because the deferred flush never runs.
  • What exit status does log.Fatal produce, and does it run defers?
    It prints its message through the standard logger and then calls os.Exit(1), so the status is 1 and no deferred call runs anywhere in the program. It is the os.Exit hazard under a shorter name, which is why it reads as harmless in code review.

saying these in an interview costs you the question

  • Thinks os.Exit unwinds the stack the way a panic does
  • Expects a deferred Close or flush to run before the exit
  • Believes os.Exit only skips defers in the calling function
  • Assumes fmt.Println output already written can be lost by os.Exit
  • Treats log.Fatal as safer than a bare os.Exit call