skip to content

Why is main in a Go program often a thin wrapper around a run() error function?

level: middleimportance: should knowfreq 45%

answer

  1. main has no way to return an error
  2. os.Exit belongs only at the top
  3. let everything below return first, then exit
  4. three lines: call it, print it, exit

basics

~10 s

os.Exit runs no deferred calls, so the only safe place to call it is main, after everything else has returned. run() does the work and returns an error; main prints it and exits non-zero.

solid answer

~50 s

`func main` cannot return an error and has no way to set a status except by calling `os.Exit`, and `os.Exit` skips every deferred call. So the idiom is to push all the work into `func run() error`, which owns its resources and cleans them up with ordinary `defer`, and keep `main` to three lines: call `run`, and if it returns an error, write it to `os.Stderr` and call `os.Exit(1)`. By the time `os.Exit` executes, `run` has already returned, so its defers have fired and its buffers are flushed. The shape also makes the program testable - a test can call `run` directly and assert on the returned error instead of spawning a process and inspecting its status. If you need more than one non-zero code, have `run` return an error type carrying the code and map it in the one place that owns the status.

code

go · 15 lines
go
func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, "migrate:", err)
		os.Exit(1)
	}
}

func run() error {
	src, err := os.Open("schema.sql")
	if err != nil {
		return err
	}
	defer src.Close() // has already run by the time main calls os.Exit
	return applyAll(src)
}

go deeper

for a junior

Learn the shape by heart: main calls run, prints any returned error to os.Stderr and calls os.Exit(1); everything else lives in run and cleans up with defer.

for a middle

Explain why the shape exists - os.Exit runs no defers, so it has to be the last thing that happens - and that run's deferred calls have all completed by the time main exits.

for a senior

Talk about what the shape buys: the program becomes testable without spawning a process, cleanup has one owner, and the mapping from failure to exit status lives in exactly one function.

for a principal

Own the exit-status contract itself: whether callers may branch on specific codes, how a new code is introduced without breaking someone's pipeline, and where that contract is written down.

## The constraint that produces the shape Two facts about Go collide: 1. `func main()` has no results, so a failure cannot leave it as a return value. The only way to end with a non-zero status is `os.Exit`. 2. `os.Exit` terminates immediately and runs **no deferred calls**, anywhere in the program. Put together: the exit must be the very last thing that happens, after all deferred cleanup has already run. The only place that is guaranteed is `main`, after the call that did the work has returned. ## The shape ``` func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } ``` `run` returns an `error`. Everything below `run` also returns errors rather than exiting. Resources are acquired and released inside `run` (or deeper) with `defer`, and every one of those defers has completed by the time control is back in `main`. If `run` returns `nil`, `main` simply falls off the end and the runtime exits with 0. Success needs no code at all - which is also why an accidentally swallowed error shows up as a green run. ## What the shape buys you **Cleanup that actually happens.** The defers are inside a function that returns normally, so they behave like defers, not like decorations. **Testability.** A test can call `run` and assert on the error. Give it parameters - arguments, an `io.Writer` for output, a `context.Context` - and most of the program becomes ordinary testable code. Testing a `main` that exits requires building the binary and running it as a subprocess, which is slower and much harder to assert on. **One place that owns the status.** The mapping from "what went wrong" to "what number the caller sees" lives in one function you can read in ten seconds. That is where the contract is documented, and where you notice if someone is about to add a fourth meaning to exit code 3. **No hidden exits.** When helpers cannot terminate the process, reading a function tells you what it can do. A `log.Fatal` buried three packages down is a control-flow surprise that no signature warns you about. ## Extending it to several statuses Define an error type that carries a code, and pull it out in `main`: - `run` returns a normal error for the ordinary failure; - for a case the caller must distinguish, it returns an error that carries a code; - `main` uses `errors.As` to extract the code, defaulting to 1 when there is none. Now the numbers are a small enumerated set defined in one file, rather than a scattering of `os.Exit(3)` calls whose meanings nobody can reconstruct. ## Where errors get printed `run` returns; `main` prints. Wrapping with `fmt.Errorf` and `%w` as the error travels up gives the single message printed at the top enough context to be useful, without the same failure appearing three times in the output at three different levels of detail. ## The exception A failure so early that there is nothing to clean up - the program cannot parse its own flags, or cannot open the one file it exists to read - is sometimes exited on the spot. That is defensible, but the moment there is a buffered writer, a temp directory or a lock file in play, the exit has to move back up to `main`.

  • What does the run() shape give you in tests?
    A test can call run directly - ideally a variant taking its arguments and an io.Writer - and assert on the returned error and the output. A main that exits can only be exercised by building the binary and running it as a subprocess, which is slower and awkward to assert on.
  • How would you support more than one non-zero exit status with this shape?
    Define an error type carrying a code, and in main use errors.As to pull it out and pass it to os.Exit, defaulting to 1 when the error carries no code. The mapping from failure to status then lives in one function, which is also where you document the codes for callers.
  • Where does the status come from if run() returns nil?
    Nowhere explicit: main falls off the end and the runtime exits with 0. Success is the path on which you write no code at all, which is exactly why an error that got swallowed somewhere below shows up as a perfectly green run.

saying these in an interview costs you the question

  • Thinks func main can return an error or an int
  • Calls os.Exit deep in a helper to save a return
  • Believes defers in main run before os.Exit does
  • Has run() print the error and exit as well as return it