Knaph

When defer Runs, and When It Doesn't

In the last lesson, defer scheduled a call to run "right before the surrounding function returns." That sentence is true. It also has two small conditions hiding inside it, and the best way to find them is to try to break it.


Student: So defer means "run this at the end of the function." Where's the catch?

Professor Sage: There isn't a catch in that sentence — it's the right picture. Let's make it precise by testing it. Start with the plain case:

package main
 
import "fmt"
 
func main() {
	fmt.Println("start")
	defer fmt.Println("deferred")
	fmt.Println("end of main")
}

What do you expect to see?

Student: start, then end of main, then deferred — the deferred line waits until main is done.

Professor Sage: Run it:

start
end of main
deferred

Exactly your prediction. Now a harder test. What happens if we put a return before the defer line?

package main
 
import "fmt"
 
func main() {
	fmt.Println("start")
	return
	defer fmt.Println("deferred")
}

Student: main returns, so... deferred still prints? You said it runs when the function returns.

Professor Sage: Run it and see.

start

Student: It never printed. But the function did return!

Professor Sage: It did. So the sentence needs one more word. Think about what defer really is: not a label that Go reads ahead of time, but a statement that executes in order, like fmt.Println does. When execution reaches a defer line, Go stores that call in a list. When the function returns, Go runs whatever is in the list. In our second program, the return came first. Execution never reached the defer line, so nothing was ever stored, so nothing ran.

go vet notices this too:

main.go:8:2: unreachable code

Student: So the rule is: a deferred call runs when the function returns, provided the defer line was reached first.

Professor Sage: That's the full rule, and you said it better than I did. It's why you see defer f.Close() written immediately after the if err != nil { return err } that follows os.Open. If the open failed, you return early, and there is nothing to close. Once the file exists, you want the defer registered before any other code gets a chance to return.

Student: Okay. Now a third test, I think. What if the function doesn't return, but the program just exits?

Professor Sage: Good instinct. Try it with log.Fatalf, which prints a message and ends the program:

package main
 
import (
	"fmt"
	"log"
)
 
func main() {
	fmt.Println("start")
	defer fmt.Println("deferred")
	log.Fatalf("exit the program")
}

This time the defer line is reached, before log.Fatalf. What do you predict?

Student: The defer was registered, and main is ending... so deferred prints?

Professor Sage: Run it.

start
2026/10/04 21:29:26 exit the program
exit status 1

Student: No deferred again! But this time it was registered. So what's different from the first experiment?

Professor Sage: In the first experiment, main returned but the defer was never registered. Here the defer was registered, but main never returned. log.Fatalf ends with a call to os.Exit(1), and os.Exit doesn't unwind the stack and return through your functions. It asks the operating system to end the whole process, right now, in the middle of whatever line it was on. The list of deferred calls is just part of that process's memory, and the process is gone before anyone reads it.

If you replace log.Fatalf("exit the program") with a plain return, you get the deferred line back, because now main really does return.

Student: So there are two separate ways for a deferred call to be skipped.

Professor Sage: Yes, and they fail for opposite reasons. Here they are side by side:

SituationWas defer reached?Did the function return?Deferred call runs?
defer, then normal end or returnyesyesyes
return before the defer linenoyesno
defer, then log.Fatalf or os.Exityesnono

Student: What about a panic? That also stops the function in the middle.

Professor Sage: A good one to separate. A panic does run deferred calls as it unwinds, which is exactly what makes recover possible — that's the next lesson. os.Exit and log.Fatal are different because they skip the unwinding entirely.

Student: Does it matter in practice? If the process is dying anyway, who cares if Close() didn't run?

Professor Sage: Often it genuinely doesn't: when a process ends, the operating system closes its open files and network connections for you. It starts to matter when the deferred call does real work — flushing a buffered writer so the last lines of output aren't lost, removing a temporary file, releasing a lock file, writing a final log line. Skipping those can leave a mess behind.

Student: Then how do I report a fatal error without skipping them?

Professor Sage: Put the real work in a function that returns an error, and let only main decide to exit:

func run() error {
	f, err := os.Create("out.txt")
	if err != nil {
		return err
	}
	defer f.Close() // runs when run() returns, even on the error paths below
 
	// ... work that may return an error ...
	return nil
}
 
func main() {
	if err := run(); err != nil {
		log.Fatalf("failed: %v", err) // by now run() has returned, so its defers already ran
	}
}

run returns, so its deferred calls run. Only after that does main call log.Fatalf, and by then there is nothing left to skip. This run() error shape is common in real Go programs for exactly this reason.


Definitions

  • Registering a deferred call — what happens when execution reaches a defer statement: Go evaluates the call's arguments immediately and stores the call to run later. A defer line that is never reached never registers anything.
  • Deferred call runs — when the surrounding function returns (by reaching its end, by a return, or while a panic unwinds it), Go runs the registered calls in last-in-first-out order.
  • os.Exit(code) — ends the whole process immediately with the given exit status. It does not return to the caller and does not run any deferred calls.
  • log.Fatal, log.Fatalf, log.Fatalln — print a message to the log, then call os.Exit(1). They skip deferred calls for the same reason os.Exit does.
  • Exit status — the number a process hands back to whoever started it. 0 means success; anything else means failure. Cron jobs and shell scripts read it.

Takeaways

What we established:

  • A defer is a statement that executes in order. A deferred call runs when its function returns, but only if execution reached the defer line first.
  • A return placed before a defer line means that defer is never registered.
  • log.Fatalf and os.Exit end the process without returning from any function, so even deferred calls that were registered are skipped.
  • Putting defer right after the code that acquires a resource, and keeping log.Fatal out of the functions that hold resources (do it once in main), keeps cleanup reliable.

What's still open: we've seen that panic runs deferred calls while it unwinds, but not what a deferred function can do with a panic. That is the job of recover, in the next lesson.

Sign in to track your progress through this course.

Ask an AI

Open a ready-made prompt in ChatGPT or Claude — just press Enter.

SummarizeChatGPTClaude
Ask me questionsChatGPTClaude
Let's learn togetherChatGPTClaude