skip to content

Why does a deferred os.RemoveAll in a Go test delete the directory before its t.Parallel subtests run?

level: middleimportance: should knowfreq 50%

answer

  1. two different endings, one test
  2. the parallel subtest parks first
  3. the parent's body returns early
  4. defer follows a function, cleanup follows a test
  5. cleanup waits for every subtest

basics

~20 s

A subtest that calls t.Parallel() is paused until the parent test function returns, and the parent's deferred calls fire at exactly that return — before the subtests execute. Register the removal with t.Cleanup, which waits for subtests.

solid answer

~40 s

`defer` is tied to a *function body*; `t.Cleanup` is tied to a *test completing*. When a subtest calls `t.Parallel()`, it signals that it wants to run alongside its parallel siblings and blocks; the parent's function body then runs to completion and returns, and only afterwards are the paused subtests released to run. Every `defer` in the parent has already fired at that point, so `defer os.RemoveAll(dir)` deletes the fixture tree out from under subtests that have not started, and they fail with "no such file or directory". A function registered with `t.Cleanup` runs when the test *and all of its subtests* have completed, which is the moment you actually want. `t.TempDir()` is the ready-made version of that — its removal is registered as a cleanup, not deferred. Cleanups run last-registered, first-called.

code

go · 20 lines
go
func TestRewrite(t *testing.T) {
	dir, err := os.MkdirTemp("", "unpack")
	if err != nil {
		t.Fatal(err)
	}
	defer os.RemoveAll(dir) // fires at the end of THIS body
	for _, name := range []string{"a.txt", "b.txt"} {
		if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o600); err != nil {
			t.Fatal(err)
		}
	}
	for _, name := range []string{"a.txt", "b.txt"} {
		t.Run(name, func(t *testing.T) {
			t.Parallel() // parks until the parent body returns
			if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
				t.Fatal(err) // the directory is already gone
			}
		})
	}
}

go deeper

for a junior

Be ready to recall that defer fires when the surrounding function returns, while t.Cleanup fires when the test and its subtests are done, and that t.TempDir uses the second one.

for a middle

Explain the actual sequence: t.Parallel parks the subtest, the parent body returns and its defers fire, then the subtests run. Naming that gap is the answer.

for a senior

Diagnose it from symptoms: subtests failing with 'no such file' or 'file already closed', flaky only under -parallel. Show how -v log lines from the defer and the cleanup expose the real order.

for a principal

Own it as a review rule: any teardown a subtest can depend on belongs in t.Cleanup, and shared fixtures should be produced by helpers that register their own cleanup so no call site can get the timing wrong.

## Two different notions of "the end" Go's testing package distinguishes two moments that look identical in a simple test and are far apart in a parallel one: - **The test function body returns.** This is what `defer` keys off. Deferred calls run in LIFO order the instant the function `TestXxx` (or the closure passed to `t.Run`) returns — including when it is stopped by `t.Fatal`. - **The test completes.** This is what `t.Cleanup` keys off, and its documented contract is "when the test *and all its subtests* complete". If a test has no subtests, both happen back to back and nothing distinguishes them. Add `t.Parallel()` and the gap becomes the whole bug. ## What t.Parallel actually does to the ordering `t.Parallel()` does not spawn anything and does not make the subtest start running immediately. It *signals* that this subtest should run in parallel with the other parallel siblings, and then **pauses** the subtest. `t.Run` returns to the parent, and the parent keeps going. Only after the parent's function body has returned are the paused parallel subtests resumed and allowed to run — together, up to the limit set by the `-parallel` flag. The parent test is not considered complete until they have all finished. So the true sequence for a parent that registers its subtests in a loop is: 1. Parent body runs, calls `t.Run` for each case; each subtest calls `t.Parallel()` and parks. 2. Parent body returns → **all of the parent's deferred calls fire here.** 3. Parallel subtests resume and run. 4. All subtests finish → the parent test completes → **the parent's `t.Cleanup` functions fire here**, LIFO. The deferred `os.RemoveAll(dir)` sits at step 2 and the subtests read the directory at step 3. The tree is gone before a single subtest looks at it. The symptom is a set of subtests that fail with `stat .../a.txt: no such file or directory`, or worse, fail only sometimes on a machine where the first subtest happens to be scheduled quickly. ## Why the deferred close of a file is the same bug The same reasoning applies to any resource the parent holds for its subtests. `defer f.Close()` in the parent closes the file before the parallel subtests read from it, and they get `file already closed`. Deferring anything a subtest depends on is wrong; it is not a temp-directory-specific rule. ## The fix Register the teardown with `t.Cleanup` instead of deferring it: ```go t.Cleanup(func() { os.RemoveAll(dir) }) ``` or, better for a directory, let `t.TempDir()` do it — its removal is registered as a cleanup at the moment you call it, so it already has the right timing. ## Why t.Cleanup exists at all, beyond this Even without parallelism, `t.Cleanup` is the better shape for **helpers**. A `defer` can only be written in the function that will return; a helper that opens a resource cannot defer its own teardown, so it has to return a `func()` and trust the caller to `defer` it — the caller can forget. A helper that takes `t *testing.T` can register the teardown itself: ```go func workDir(t *testing.T) string { t.Helper() dir := t.TempDir() // ... populate the tree ... return dir } ``` Now the cleanup travels with the helper and cannot be dropped at a call site. ## Ordering rules to have ready - Cleanups run **last registered, first called**, the same LIFO order as `defer`. If you take a directory and then open a file inside it, register the close after taking the directory and the close runs first — the order you want. - Cleanups run whether the test **passed, failed, or was stopped by `t.Fatal` or `t.Skip`**. They are teardown, not a success path. - A cleanup registered inside a subtest belongs to that subtest and runs when it finishes, not at the end of the parent. - A cleanup may itself register another cleanup; it will also run. - Cleanups on the parent run **after** every subtest's cleanups, since the parent completes last. ## The onboarding trap This bug is almost always copied rather than invented: an engineer lifts a `defer os.RemoveAll(dir)` out of a neighbouring sequential test into a table-driven test whose subtests call `t.Parallel()`, and the pattern that was correct in its original home is wrong in the new one. When you review a test that mixes `t.Parallel()` with `defer`, look at what the deferred call touches: if any subtest depends on it, it must be a cleanup. Running with `-v` and logging from both the deferred function and the cleanup makes the real order visible in one run and settles the argument quickly.

  • Does a function registered with t.Cleanup still run if the test calls t.Fatal?
    Yes. Cleanups are teardown, not a success path: they run whether the test passed, failed, was stopped by Fatal, or was skipped. That is another reason to put resource release there rather than at the bottom of the test body, where an early exit would skip it.
  • A helper opens a resource the test needs. Where should its teardown live?
    Inside the helper. Give it a t *testing.T parameter and let it call t.Cleanup itself, so the teardown travels with the resource. Returning a func() for the caller to defer works but relies on every call site remembering, and one that forgets leaks silently.
  • In what order do several t.Cleanup functions on the same test run?
    Last registered, first called, the same LIFO order as defer. That matters when one cleanup depends on another: register the temp directory first and the file close after it, and the close runs before the directory is removed.
  • Does a cleanup registered inside a subtest wait for the parent to finish?
    No. It belongs to that subtest and runs when that subtest completes, which for a parallel subtest is well before the parent finishes. Only the parent's own cleanups wait for all of its subtests.

A defer is the caterer packing up when the host leaves the room; a cleanup is the caterer packing up when the last guest goes home. With parallel subtests the guests only arrive after the host has left.

saying these in an interview costs you the question

  • Thinks t.Parallel starts the subtest immediately beside the parent
  • Believes deferred calls in the parent run after its subtests
  • Says cleanup functions run in registration order
  • Claims cleanups are skipped when the test fails
  • Fixes it by removing t.Parallel instead of moving the teardown