Why should log.Fatal never appear in a Go library or a helper function?
answer
- Fatal is Print plus something
- os.Exit(1) hiding inside a helper
- what happens to every pending defer?
- the half-written lock file survives
- the caller never got to decide
basics
~20 slog.Fatal prints the message and then calls os.Exit(1), so no deferred function runs. Inside a library or helper it kills the caller's whole process, skips its cleanup, and removes any chance to handle the failure. Return an error instead.
solid answer
~50 s`log.Fatal` is `log.Print` followed by `os.Exit(1)`, and `os.Exit` terminates immediately without running any deferred function anywhere in the program. A helper that calls it has decided, on behalf of every possible caller, that this failure is fatal - so a `defer` that would remove a lock file, roll back a partial write or close a database handle never runs, and the caller cannot retry, fall back or even report the failure in its own words. It also makes the path untestable: a test that reaches the call ends the whole test binary. The fix is the ordinary Go one - return the error, wrapped with what this layer knows - and let the single top-level owner decide whether it is fatal. `log.Fatal` is defensible only in `main` itself, and even there most code prefers `main` calling a `run() error` and exiting once at the end.
code
go · 8 lines// log.Fatalf is log.Printf followed by os.Exit(1).
func mustEnvInt(name string) int {
v, err := strconv.Atoi(os.Getenv(name))
if err != nil {
log.Fatalf("%s: %v", name, err) // no deferred function anywhere runs
}
return v
}go deeper
Remember what the name hides: log.Fatal prints and then ends the process. Outside main, return an error instead so the caller can decide.
Explain the mechanics precisely - Print plus os.Exit(1), no deferred function runs anywhere - and contrast it with log.Panic, which unwinds and can be recovered.
Describe real damage you would expect: a lock file or temporary directory left behind, a pending flush lost, and a test binary that dies mid-run so no other test reports.
Own the boundary rule that only main may end the process, and be able to say how you enforce it across repositories rather than relying on reviewers spotting each call.
## What log.Fatal actually is The standard `log` package's `Fatal`, `Fatalf` and `Fatalln` are documented as equivalent to the matching `Print` call **followed by `os.Exit(1)`**. There is no magic: a message goes to the logger's output (standard error by default, with a date and time prefix), and then the process terminates with status 1. The important half is `os.Exit`. It ends the program right there - **deferred functions do not run**, in the calling goroutine or anywhere else, and other goroutines are not given a chance to finish. Anything a program was relying on `defer` to do simply does not happen. ## What that costs when a helper calls it Consider a scheduled job whose helper parses configuration out of environment variables at startup: ```go func mustEnvInt(name string) int { v, err := strconv.Atoi(os.Getenv(name)) if err != nil { log.Fatalf("%s: %v", name, err) } return v } ``` If that helper is called after the job has already created a lock file and registered `defer os.Remove(lockPath)`, the lock file survives the exit. The next scheduled run finds a lock nobody holds and refuses to start, and the incident is now two incidents. The same story covers a half-written output file that a deferred rename would have made atomic, a temporary directory a deferred cleanup would have removed, and an in-flight batch a deferred flush would have committed. The deeper problem is authority. A library or helper cannot know what the failure means to its caller. A missing environment variable is fatal to a one-shot job, a reason to fall back to a default in a long-running server, and just an expected condition in a test that is checking the error path. By calling `log.Fatal` the helper takes that decision away from every caller it will ever have. ## What it does to tests A test that exercises the failing path does not fail - it **terminates the test binary**. No remaining tests run, no per-test output is printed, and the reported result is an exit status rather than an assertion message. Testing a fatal path at all requires re-executing the test binary as a subprocess, which is a lot of machinery to work around a line that should have returned an error. ## The fix Return the error and wrap it with what this layer knows: ```go func envInt(name string) (int, error) { v, err := strconv.Atoi(os.Getenv(name)) if err != nil { return 0, fmt.Errorf("env %s: %w", name, err) } return v, nil } ``` Now a server can substitute a default, a job can abort cleanly with its defers running, and a test can assert on the returned error in a single line. ## Where a fatal call is still acceptable `main` is the one function that legitimately owns the decision to stop the process, because there is nobody above it. Even there, the common shape is to keep the exit at a single point so that everything else can use `defer` normally: `main` calls a `run() error`, and only after `run` has returned - with all of its deferred cleanup finished - does `main` report the error once and exit non-zero. A related, narrower exception is a `Must`-style helper used in package-level variable initialisation of a program that genuinely cannot start without the value - `template.Must` and `regexp.MustCompile` follow this pattern, but note that those **panic** rather than exit. A panic still runs deferred functions as the stack unwinds and can be recovered by a caller that wants to; `os.Exit` offers neither. ## How to spot it Grep the repository for `log.Fatal` and `os.Exit` and check the package of every hit. Anything outside the main package - and outside `main` itself - deserves a question in review. A convention that says "only `main` may end the process" is easy to state, easy to check, and removes an entire class of half-finished-cleanup incidents.
- How does log.Panic differ from log.Fatal in a helper?`log.Panic` prints and then panics, so deferred functions still run as the stack unwinds and a caller can `recover`. `log.Fatal` calls `os.Exit(1)`, which runs nothing and cannot be intercepted. Panicking is the lesser evil, but in library code returning an error is still the right answer.
- What shape lets main exit non-zero while everything else still uses defer normally?Keep the exit at one point: `main` calls a `run() error` that owns all the setup and `defer`s, and only when `run` has returned - cleanup already done - does `main` report the error once and exit non-zero. Nothing below `run` ends the process.
- Why is a package-level MustCompile-style helper tolerated when log.Fatal is not?Those helpers panic rather than exit, so deferred functions still run and a caller could recover, and they are used for values that are constant in the source - a bad regexp is a programming error found at start-up, not a runtime condition a caller could handle.
saying these in an interview costs you the question
- log.Fatal just exits, defers still run first
- A deferred recover can catch log.Fatal
- Only the calling goroutine ends
- It is fine in a library because the error is unrecoverable anyway
- Fatal is a stronger log level, not a control-flow change