Skip to content
Latchkey

Go t.Parallel() Loop-Variable Failures - Fix Parallel Tests in CI

Table-driven subtests that call t.Parallel() all observe the same (usually last) case, because the parallel closures captured a shared loop variable. Under older loop-variable semantics every subtest reads the variable after the loop finished.

What this error means

A parallel table test fails or asserts against the wrong case - every subtest behaves as if it got the final iteration’s data. It can also pass by luck, making it intermittently wrong rather than reliably failing.

go test output
--- FAIL: TestCases/case-a (0.00s)
    cases_test.go:25: got result for "case-z", want "case-a"
# all parallel subtests saw the last loop value

Common causes

Loop variable captured by a parallel closure (pre-1.22)

Before Go 1.22, the range variable was shared across iterations. A t.Parallel() subtest runs after the loop advanced, so its closure reads the final value.

Building with an older go directive

A module whose go directive is below 1.22 keeps the old shared-variable semantics even on a newer toolchain, so the bug persists.

How to fix it

Bind the case to a local variable

Shadow the loop variable inside the loop so each subtest closes over its own copy.

Go
for _, tc := range cases {
	tc := tc           // pin per iteration (pre-1.22)
	t.Run(tc.name, func(t *testing.T) {
		t.Parallel()
		// use tc...
	})
}

Raise the go directive to 1.22+

Go 1.22 changed loop semantics so each iteration gets a fresh variable, removing the need to re-bind.

Terminal
go mod edit -go=1.22
go test ./...

How to prevent it

  • Re-bind range variables before t.Parallel() on pre-1.22 modules.
  • Set the go directive to 1.22+ to get per-iteration loop variables.
  • Run go test -shuffle=on and the race detector to surface capture bugs.

Related guides

Tired of flaky CI? Latchkey auto-heals failed jobs and retries them for you. Start free →