The cleanup trap
You write a test that creates a temporary file to verify file I/O. The test passes. You run the suite again. It fails because the file already exists. Or worse, you run the suite a hundred times in CI and your disk fills up with orphaned temp files. You add a defer to clean up, but then you realize defer only runs when the function returns, and if the test logic is split across helpers or subtests, the cleanup scope gets confusing. You need a mechanism that ties cleanup directly to the test lifecycle, runs in the right order, and survives failures.
t.Cleanup is that mechanism. It registers a function to run after the test finishes, regardless of success or failure. It integrates with the test runner's stack, supports subtests, and lets you register cleanup from helper functions.
What t.Cleanup does
t.Cleanup takes a function with no arguments and no return values. It stores that function in a list attached to the *testing.T object. When the test function returns, the testing framework executes all registered cleanup functions in reverse order. The last cleanup you register runs first. This is LIFO order, like a stack of plates.
Think of t.Cleanup as a promise to the test runner. You say, "I'm using this resource. When I'm done, no matter what happens, please run this function to put things back the way they were." The runner keeps a stack of these promises. When the test ends, it pops them off and executes them one by one.
Cleanup functions run even if the test calls t.Fatal or t.FailNow. They also run if the test panics. This makes t.Cleanup safer than manual cleanup code that might get skipped by an early return.
Cleanup is a promise. Keep it.
Minimal example
Here's the simplest pattern: create a resource, register the cleanup immediately, then run the test logic.
package main
import (
"os"
"testing"
)
// TestCleanupBasic shows how to register a cleanup function.
func TestCleanupBasic(t *testing.T) {
// Create a temp file to simulate a resource that needs cleanup.
f, err := os.CreateTemp("", "test-*.txt")
if err != nil {
t.Fatal(err)
}
// Register cleanup to remove the file after the test finishes.
// This runs in LIFO order, so the last cleanup runs first.
t.Cleanup(func() {
// Remove the file using the name captured in the closure.
os.Remove(f.Name())
})
// Test logic: write data to verify the file works.
if _, err := f.WriteString("hello"); err != nil {
t.Fatal(err)
}
}
Register cleanup immediately after allocation. Don't wait.
How the runner executes cleanup
When TestCleanupBasic calls t.Cleanup, the function isn't executed. It's stored in a list inside the *testing.T object. The test continues running. When the test function returns, either normally or via t.Fatal, the framework iterates over the cleanup list in reverse order and calls each function.
If the test calls t.Fatal, the test function stops executing, but the cleanup functions still run. This is crucial for resources like files or network listeners. If cleanup didn't run on failure, every failed test would leak resources.
Cleanup functions can call t.Log or t.Error. If a cleanup function calls t.Error, the test is marked as failed, and the error message appears in the output. This is useful for reporting cleanup issues without panicking. If a cleanup function panics, the framework recovers the panic, logs it, and continues to the next cleanup function. This prevents one bad cleanup from blocking others.
The LIFO order matters when resources depend on each other. If you create a directory and then a file inside it, you register the directory cleanup first, then the file cleanup. When the test ends, the file cleanup runs first, deleting the file. Then the directory cleanup runs, deleting the now-empty directory. If the order were reversed, you'd try to delete a non-empty directory and fail.
Register cleanup immediately after allocation. Don't wait.
Realistic pattern: test helpers
In real code, you rarely write cleanup logic inline for every resource. You wrap allocation in a helper function that takes *testing.T, creates the resource, registers the cleanup, and returns the resource. This keeps test bodies clean and ensures every allocation has a matching cleanup.
// createTempDir is a helper that creates a directory and registers cleanup.
func createTempDir(t *testing.T) string {
t.Helper()
// Create a temporary directory for the test.
dir, err := os.MkdirTemp("", "testdir-*")
if err != nil {
t.Fatal(err)
}
// Register cleanup to remove the directory and its contents.
// Using os.RemoveAll ensures nested files are also deleted.
t.Cleanup(func() {
os.RemoveAll(dir)
})
return dir
}
The helper uses t.Helper() to mark itself as a helper. This tells the test runner to skip the helper when reporting line numbers in error messages. Without t.Helper(), errors inside the helper would point to the helper function, not the test that called it.
// TestHelperCleanup shows how helpers can manage resources.
func TestHelperCleanup(t *testing.T) {
// Get a temp dir that cleans itself up automatically.
dir := createTempDir(t)
// Use the directory for test logic.
path := filepath.Join(dir, "file.txt")
if err := os.WriteFile(path, []byte("data"), 0644); err != nil {
t.Fatal(err)
}
// Verify the file exists.
if _, err := os.Stat(path); err != nil {
t.Fatal(err)
}
}
Helpers make tests readable. Let the helper manage the lifecycle.
Pitfalls and subtests
Loop variables are a common trap. If you register cleanup inside a loop and capture the loop variable, all cleanup functions might see the same final value. Go 1.22+ catches this at compile time.
// BAD: captures loop variable in cleanup.
func TestLoopCapture(t *testing.T) {
for i := 0; i < 3; i++ {
// This captures the loop variable i.
t.Cleanup(func() {
t.Log(i)
})
}
}
If you capture a loop variable in a cleanup function, the compiler rejects the program with loop variable i captured by func literal in Go 1.22+. Before that version, it was a silent bug where all cleanups saw the final value of the loop variable. To fix this, capture the variable in a new local variable inside the loop.
// GOOD: captures a copy of the loop variable.
func TestLoopCaptureFixed(t *testing.T) {
for i := 0; i < 3; i++ {
// Create a local copy of i for this iteration.
idx := i
t.Cleanup(func() {
t.Log(idx)
})
}
}
Subtests have their own cleanup stacks. When a subtest ends, its cleanups run. The parent's cleanups wait until all children are done. This isolation prevents one subtest's cleanup from interfering with another.
// TestSubtestCleanup shows cleanup isolation in subtests.
func TestSubtestCleanup(t *testing.T) {
// Parent cleanup runs after all subtests finish.
t.Cleanup(func() {
t.Log("Parent cleanup")
})
t.Run("child", func(t *testing.T) {
// Child cleanup runs when the subtest finishes.
t.Cleanup(func() {
t.Log("Child cleanup")
})
})
}
The output shows "Child cleanup" first, then "Parent cleanup". This matches the LIFO order across the subtest hierarchy.
Loop variables are traps. Capture them or the compiler will catch you.
When to use t.Cleanup
Use t.Cleanup when you need to release resources like files, directories, or network listeners at the end of a test. Use t.Cleanup when you want cleanup to run even if the test fails or calls t.Fatal. Use t.Cleanup when you register cleanup from a helper function that returns a resource to the test. Use defer inside a test function when you are cleaning up a resource that doesn't depend on the test runner, such as closing a local buffer or unlocking a mutex that isn't part of the test fixture. Use manual cleanup code only when you need to verify the cleanup result immediately within the test logic, which is rare.
t.Cleanup owns the test lifecycle. defer owns the function scope.