skip to content

os.Exit and runtime.Goexit

Three ways out with three different contracts: os.Exit stops the process at once and skips defers, runtime.Goexit runs a goroutine's defers then retires it, and main returning ends everything.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

4

In Go, what happens to deferred functions and buffered output when a program calls os.Exit?

level: juniorimportance: must knowfreq 65%

answer

  1. There is more than one way out
  2. defer is tied to returning
  3. Some bytes never reached the kernel
  4. log.Fatal is not just a log line
  5. Flush yourself, then exit

basics

~10 s

os.Exit ends the process immediately. Deferred functions never run, so anything a defer would have flushed or closed is lost: bytes still sitting in a bufio.Writer never reach the file or the terminal.

solid answer

~50 s

`os.Exit` terminates the process right there with the status you give it. It does not unwind the stack, so no deferred call runs — not in the calling function, not anywhere up the chain, and not in any other goroutine. That matters most for output held in Go's own memory: a `bufio.Writer`, an encoder wrapping one, or anything whose contents only reach the operating system when you call `Flush`. Bytes already handed to the kernel by `os.File.Write` survive, because they are no longer the program's problem; bytes in a user-space buffer are simply discarded with the process. `log.Fatal` and `log.Fatalf` are the same hazard wearing a friendlier name — they print a line and then call `os.Exit(1)`. The practical rule is to flush before you exit, and to keep the exit call itself in one place near the top of the program.

code

go · 7 lines
go
func main() {
	w := bufio.NewWriter(os.Stdout)
	defer w.Flush()
	fmt.Fprintln(w, "row 1")
	os.Exit(1)
}
// prints nothing: os.Exit runs no defers, and "row 1" is still in the writer

go deeper

for a junior

Be ready to say plainly that os.Exit stops the process on the spot and that deferred calls do not run. Know that log.Fatal is an exit call, and that anything buffered in a bufio.Writer is gone.

for a middle

An interviewer expects you to explain why: defer is bound to a function returning, and os.Exit produces no return. Distinguish in-process buffering from bytes the kernel already accepted.

for a senior

Show that you can spot this in code review: an exit call in a helper silently discards every caller's cleanup. Explain how you would restructure so flushing always precedes the exit, and how you would verify it.

for a principal

Own the rule about where a process may end at all. Argue for a single exit point, weigh it against the convenience of log.Fatal in one-shot tools, and say how the rule survives contact with a codebase that already breaks it.

## What `os.Exit` actually does `os.Exit(code)` asks the operating system to terminate the process immediately, handing back `code` as the exit status. Zero conventionally means success, non-zero means failure. The important word is *immediately*: the Go runtime does not unwind any goroutine's stack, does not run pending deferred calls, does not wait for other goroutines to finish what they are doing, and does not give the standard library a chance to tidy up. The process is gone at that statement. This is deliberately different from the other two ways a Go program can end: - **Returning from `main`.** The main function's own deferred calls run, then the runtime exits the process with status 0. Other goroutines are killed where they stand and *their* defers do not run. - **An unrecovered panic.** The panicking goroutine unwinds, running its deferred calls on the way out (that is how `recover` gets a chance), then the runtime prints the panic value and a stack dump and kills the process. `os.Exit` sits outside both: no unwinding, no defers, no dump. ## Why `defer` cannot help you here `defer` is a per-function mechanism. A deferred call is pushed onto the goroutine's list of pending calls and runs when that function *returns* — normally or through a panic. `os.Exit` produces neither of those events. There is no return and no unwinding, so the list is never walked. The defers are not skipped by policy; there is simply no moment at which they would be run. A very common shape gets this wrong: ```go func main() { w := bufio.NewWriter(os.Stdout) defer w.Flush() fmt.Fprintln(w, "row 1") os.Exit(1) } ``` This prints nothing at all. The `defer w.Flush()` looks like a guarantee, and it is one — but only against a return. ## What "buffered output" means in Go Go has two layers of buffering, and only one of them is yours to lose: 1. **User-space buffering inside your process.** `bufio.NewWriter` allocates a byte slice (4096 bytes by default) and holds your writes there, handing them to the underlying writer only when the buffer fills or when you call `Flush`. `bytes.Buffer`, `strings.Builder` and encoders layered on a `bufio.Writer` behave the same way. All of this lives in Go memory and vanishes with the process. 2. **Kernel buffering.** Once `os.File.Write` has been called, the bytes are in the operating system's page cache. `os.Exit` does not endanger them; the kernel writes them out. (Only a machine crash threatens those, which is what `os.File.Sync` is for.) So the question to ask of any lost output is: had `Write` on the file already been called for those bytes? If the answer is no because they were still in a `bufio.Writer`, `os.Exit` is your prime suspect. Note also that `os.Stdout` in Go is *not* buffered by the standard library — `fmt.Println(...)` writes straight through. Output only goes missing when you deliberately wrapped something in a buffered writer. ## `log.Fatal` is `os.Exit` in disguise `log.Fatal`, `log.Fatalf` and `log.Fatalln` print the message through the logger and then call `os.Exit(1)`. They are not a logging call with extra emphasis; they are a process exit. Every consequence above applies, which is why a `log.Fatalf` buried in a helper function is such an effective way to lose the tail of a report file. ## Structuring exits so nothing is lost The reliable pattern is to make the exit call the last thing the program does, after the flushing has already happened: - Have the code that produces output return an error rather than exiting. - Let `main` receive that error, flush explicitly (and check the error `Flush` returns — a failed flush is a lost report too), and only then call `os.Exit` with a status. - Remember that a `defer w.Flush()` *inside* `main` still does not save you if `main` itself finishes with `os.Exit`; the defer only runs if `main` returns. Either flush explicitly before exiting, or do the work in a helper whose return triggers the defer, and exit after it. ## What an interviewer is checking That you know `defer` is tied to function return rather than to "the end of the program", that you can name what is lost (in-process buffers, not kernel-accepted writes), and that you recognise `log.Fatal` as an exit call rather than a print.

  • Do bytes already written with os.File.Write survive os.Exit?
    Yes. Once `Write` has returned, the bytes are with the operating system and it will put them on disk whether or not your process is still alive. What dies with the process is anything still held in Go memory — a `bufio.Writer`'s buffer, a `bytes.Buffer`, an encoder that has not been flushed. That is why `os.Exit` truncates reports at a buffer boundary rather than losing the whole file.
  • Does log.Fatalf have the same effect as os.Exit?
    Yes. `log.Fatal`, `log.Fatalf` and `log.Fatalln` write the message through the logger and then call `os.Exit(1)`. No defers run, no other goroutine gets to finish, and nothing buffered gets flushed. Treating them as ordinary logging is how process exits end up scattered through helper packages.
  • How do you keep a non-zero exit status and still flush your output?
    Do not exit where the failure is detected. Let that code return an error, have `main` flush the writer explicitly — checking the error `Flush` returns — and only then call `os.Exit(1)`. A `defer w.Flush()` in `main` is not enough on its own, because it runs only if `main` actually returns.

os.Exit is pulling the plug on the machine, not shutting it down: nothing gets a chance to write out what it was still holding.

saying these in an interview costs you the question

  • Thinks deferred functions always run, even on os.Exit
  • Assumes os.Exit unwinds the stack the way a panic does
  • Believes a deferred Close flushes a bufio.Writer
  • Treats log.Fatal as an ordinary logging call
  • Expects the runtime to flush buffered writers at exit
  • Says the data was never written, when it was only never flushed
open as a page

How does runtime.Goexit differ from returning from a goroutine's function, and from os.Exit?

level: middleimportance: should knowfreq 38%

basics

~20 s

runtime.Goexit ends the calling goroutine from anywhere in its stack, running every pending deferred call on the way out; recover returns nil, since this is not a panic. os.Exit instead ends the whole process and runs no defers.

open as a page

A Go migration tool writes its report through a bufio.Writer, and on failed CI runs the report is truncated — how do you find the cause?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Suspect an error path that calls os.Exit or log.Fatal, so the deferred Flush never runs and buffered bytes are dropped. The tell is that successful runs produce a complete report while failing runs stop mid-buffer.

open as a page

What rule would you set for os.Exit and log.Fatal in shared Go packages, and how would you enforce it?

level: principalimportance: nice to knowfreq 28%

basics

~20 s

Confine process exit to package main. A shared package calling os.Exit or log.Fatal takes a whole-program decision away from every caller and skips their cleanup, so libraries return errors and one place in main flushes, then exits with a status.

open as a page