skip to content

Why is calling t.Fatalf from a goroutine your Go test spawned unsafe?

level: middleimportance: should knowfreq 48%

answer

  1. Fatal is not a return statement
  2. it ends one goroutine, not the test
  3. runtime.Goexit unwinds only its own stack
  4. Errorf is safe anywhere, Fatal is not
  5. send the error home and fail at the join

basics

~20 s

Fatalf records the failure and then ends only the goroutine that called it, through runtime.Goexit. The test function keeps running with the broken state, and if it returns first the late call panics. From a spawned goroutine use Errorf and return, or send the error back.

solid answer

~50 s

`(*testing.T).Fatalf` is `Errorf` followed by `FailNow`, and `FailNow` is documented to work only from the goroutine running the test function: it calls `runtime.Goexit`, which unwinds and terminates *that* goroutine and nothing else. Called from a goroutine the test started, it marks the test failed but never stops the test function, which sails on with the state that was already wrong — often to a nil dereference or a misleading second failure. If the test function happens to return first, the goroutine's logging call panics with "Log in goroutine after TestX has completed". The safe methods from another goroutine are `t.Log`, `t.Error` and `t.Errorf`, which are documented as safe for concurrent use. The clean pattern is to send the error back on a buffered channel and call `t.Fatal` at the join point, on the test goroutine. `go vet`'s `testinggoroutine` analyzer flags exactly this mistake.

go deeper

for a junior

Remember the rule and the fix: from a spawned goroutine use t.Errorf and return, and keep t.Fatal for the test function itself. Be able to say that Fatal ends only the goroutine that called it.

for a middle

Explain the mechanism — Fatalf is Errorf plus FailNow, and FailNow calls runtime.Goexit — and walk through the two outcomes: a test that keeps running with bad state, or a panic about logging after the test completed.

for a senior

Show where this hides in a real suite: handlers on a test server, callbacks, subscriber functions. Demonstrate the pattern of returning errors to the join point, and mention that go vet's testinggoroutine analyzer catches the direct cases only.

for a principal

Own the convention across the codebase: assertions belong on the test goroutine, workers return errors. Be ready to say which vet analyzers gate CI and why review still has to catch what static analysis cannot see through a callback.

## What Fatalf actually does `t.Fatalf(format, args...)` is two steps: it logs the message and marks the test as failed (the `Errorf` half), then it calls `FailNow`. And `FailNow` is not a `return` and not a `panic` — it calls `runtime.Goexit()`. `runtime.Goexit` terminates the calling goroutine. It runs that goroutine's deferred functions and then the goroutine is gone; no other goroutine is affected, and the program does not exit. That is precisely how the testing package stops a test at the point of a fatal error: the test function *is* a goroutine, so ending it ends the test, and the testing package's own machinery notices and moves on. ## Why that breaks from a spawned goroutine The documented contract is that `FailNow`, and therefore `Fatal`/`Fatalf`/`SkipNow`, must be called from the goroutine running the test or benchmark function, not from other goroutines created during the test. Break it and three things can happen, in rough order of nastiness: 1. **The test does not stop.** The failure flag is set, but the test function is a different goroutine and keeps executing. It carries on with the state that the worker just declared invalid — typically producing a cascade of further failures, or a nil dereference whose stack points nowhere near the real problem. 2. **The fatal call is lost in a race with the test's own completion.** If the test function returns before the worker gets to its `t.Fatalf`, the call happens after the test has finished, and the testing package panics with `Log in goroutine after TestX has completed`. That panic takes down the whole test binary, and the message is about the *logging*, not about the actual bug. 3. **The worker silently disappears.** `Goexit` kills that goroutine on the spot. Anything the worker still had to do — closing a channel other goroutines are waiting on, releasing a resource, signalling completion — never happens, so a test that would have failed cleanly now hangs somewhere else. ## What is safe from another goroutine `testing.T`'s reporting methods split into two groups: - **Safe for concurrent use from any goroutine:** `Log`, `Logf`, `Error`, `Errorf`, `Fail`, `Failed`, `Name`. `Error` is `Log` plus `Fail`; it marks the test failed and *returns*, which is exactly what you want from a worker. - **Test-goroutine only:** `FailNow`, `Fatal`, `Fatalf`, `SkipNow`, `Skip`, `Skipf`, and `t.Parallel`. So the minimum fix is mechanical: in a spawned goroutine, replace `t.Fatalf(...)` with `t.Errorf(...)` followed by an explicit `return`. The `return` matters — without it you lose the fail-fast behaviour you were relying on and the goroutine proceeds into the state it just complained about. ## The better shape: report at the join point Better still, do not report from the worker at all. Hand the error back to the test goroutine, which is the one allowed to stop the test: ```go errCh := make(chan error, 1) go func() { errCh <- svc.Warm(ctx) }() if err := <-errCh; err != nil { t.Fatal(err) } ``` The channel is buffered so the worker can finish even if the test abandons the wait; the `t.Fatal` runs on the test goroutine, where it means what it says. This also concentrates the assertions in one readable place instead of scattering them across closures. ## The trap that bites in real suites The rule is easy to obey in a plain `go func(){}` and easy to forget everywhere else, because any callback the test provides may be invoked on somebody else's goroutine. The classic case is a test HTTP handler: `httptest.NewServer` serves each request on its own goroutine, so a `t.Fatalf` inside the handler is exactly this bug wearing a disguise. The same applies to a callback passed into the code under test, an `http.Client` transport hook, or a subscriber function invoked by a background loop. If the function might be called by code you did not `go` yourself, treat it as another goroutine. ## Tooling `go vet` ships a `testinggoroutine` analyzer that reports calls to `Fatal`, `FailNow` and friends from goroutines started by a test — it catches the obvious direct cases. It cannot see through an interface or a callback you registered, so the review habit still matters: any `testing.T` inside a closure deserves a second look at which goroutine will run it. ## The one-line rule Fail fast only from the goroutine that owns the test; from everywhere else, record with `Errorf` and return, or send the error home.

  • Which testing.T methods are safe to call from a goroutine the test started?
    `Log`, `Logf`, `Error`, `Errorf`, `Fail`, `Failed` and `Name` are documented as safe for concurrent use. `FailNow`, `Fatal`, `Fatalf`, `SkipNow`, `Skip` and `t.Parallel` must be called from the goroutine running the test function, because stopping the test means ending that goroutine.
  • A leftover goroutine calls t.Logf after its test has returned. What happens?
    The testing package panics with "Log in goroutine after TestX has completed", which aborts the test binary. It is really a leak report in disguise: a goroutine outlived the test that owned it, so the fix is to join it before the test function returns, not to remove the log line.
  • Why is a t.Fatalf inside an httptest server handler the same bug?
    The test server serves each request on its own goroutine, so the handler is not running on the test goroutine. The Fatalf ends the handler mid-response — the client may see a truncated reply or an EOF — while the test function continues. Record the problem and assert on it after the request returns.

saying these in an interview costs you the question

  • Thinks t.Fatal stops the test from any goroutine
  • Believes runtime.Goexit unwinds the caller too
  • Uses t.Fatalf inside a test HTTP handler
  • Replaces Fatalf with Errorf but forgets the return
  • Treats the late-log panic as a logging bug