Why did Go table-driven tests before 1.22 start each loop iteration with tc := tc?
answer
- closures capture variables, not values
- one variable shared by every iteration
- the closure outlives the row
- the shadow line that looks like a no-op
- go.mod decides, not the toolchain
basics
~20 sBefore Go 1.22 a range loop reused one tc variable across iterations, so a closure outliving its iteration read the last row's values. The tc := tc line made a per-iteration copy; Go 1.22 made it unnecessary.
solid answer
~50 sIn Go 1.21 and earlier, `for _, tc := range cases` declared `tc` **once** and reassigned it each iteration. Any function literal that captured `tc` captured that single variable, not a snapshot — so a closure still alive after the loop advanced saw whatever the last iteration had left there. In a table-driven test that meant every case checking the final row, which typically shows up as a suite that passes for the wrong reason or fails identically on every case. The idiomatic fix was the shadow line `tc := tc` as the first statement in the body, giving each iteration its own variable to capture. Go 1.22 changed the language so each iteration declares a fresh loop variable, so the shadow is dead code in modules whose `go.mod` declares 1.22 or later. Note the gate is the module's declared language version, not the installed toolchain — a module still saying `go 1.21` keeps the old behaviour on a current toolchain.
code
go · 12 lines// Go 1.21 and earlier: one tc variable is shared by every iteration.
var checks []func()
for _, tc := range cases {
checks = append(checks, func() { fmt.Println(tc.name) })
}
// Running every func in checks prints the LAST case's name, once per closure.
// The line that was in every table-driven test before 1.22:
for _, tc := range cases {
tc := tc // a fresh variable, one per iteration
checks = append(checks, func() { fmt.Println(tc.name) })
}go deeper
Recognise the tc := tc line in old test files and know it made a per-iteration copy of the case, and that Go 1.22 made it unnecessary in modules that declare that version.
Explain the mechanism: a closure captures the variable, the pre-1.22 range loop had exactly one of them, so any use that outlived the iteration read the final row.
Show how the bug presents in production CI - a green suite covering one input forty times - and how you would confirm it, including checking the go.mod line before deleting the shadow.
Own the migration angle: a language change gated on the module's declared version means bumping that line silently alters existing behaviour, so decide deliberately when and how the repository moves.
## The shape of the bug Go closures capture **variables**, not values. Whether that matters depends on how long the variable lives and how long the closure lives. Under the pre-1.22 rules, this loop: ```go for _, tc := range cases { // ... } ``` declared `tc` **once**, before the first iteration, and assigned the next element into that same storage each time round. So a function literal written inside the body that mentions `tc` held a reference to one shared variable shared by all iterations. If that function ran *during* its own iteration, everything was fine — the variable held the right row. If it ran *later*, it read whatever the loop had left behind, which after the loop finished was the final case. Every stored closure then behaved like the last row. ```go var checks []func() for _, tc := range cases { checks = append(checks, func() { fmt.Println(tc.name) }) } // Go 1.21: calling all of these prints the last case's name, once per closure. ``` ## Why table-driven tests were the classic victim The pattern puts a function literal inside a range loop by construction — that is what `t.Run(tc.name, func(t *testing.T) { ... })` is. A plain `t.Run` call is safe even under the old rules, because it runs its function and waits for it before the loop advances, so the shared variable still holds the right row while the subtest executes. The bug appears the moment the case body's use of `tc` **outlives the iteration**: the subtest starts a goroutine that reads `tc.in` and is not waited for, the body registers a callback or a cleanup that reads `tc` later, or the loop collects closures to invoke afterwards. Then the case that finally reads `tc` gets the last row. The failure is nastily quiet. Every case exercises the same input, so a table of forty rows tests one row forty times, and a suite can stay green while covering almost nothing. Nobody sees a panic; the coverage simply is not there. Because it was hard to know in advance whether some future edit inside the body would make the capture outlive the iteration, the community adopted a blanket habit: shadow the variable unconditionally. ```go for _, tc := range cases { tc := tc // fresh variable, one per iteration t.Run(tc.name, func(t *testing.T) { /* ... */ }) } ``` The line looks like a no-op and confuses newcomers, which is exactly why it needed a comment — `tc := tc` declares a **new** `tc` scoped to this iteration of the block, initialised from the outer one, and the closure then captures the new one. The same trick was written as `tc := tc` for structs and `i := i` for indices. ## What Go 1.22 changed Go 1.22 changed the semantics of `for` loops so that **each iteration declares its own copy of the loop variables**, for both range loops and three-clause `for i := 0; ...` loops. A closure created in iteration three captures iteration three's variable, and nothing the loop does afterwards can change what it sees. The shadow line became unnecessary; you should delete it when you see it, and modern linters flag it as redundant. The part people get wrong: **the change is gated on the language version of the file, which comes from the `go` line in the module's `go.mod`.** A module that still declares `go 1.21` compiles with the old, shared-variable semantics even under a current toolchain — the Go project made the change opt-in this way precisely because it alters the meaning of existing programs. So the honest answer to "do I still need `tc := tc`?" is: check `go.mod`. If it says 1.22 or later, no. If it says something older, the old rules are still in force for that module and the shadow line is still doing real work. This is also the reason the fix is worth understanding rather than memorising. Old repositories, vendored code and examples on the internet still carry the pattern; a reviewer who cannot say *why* it was there cannot safely decide whether removing it is a cleanup or a regression. ## What to check in review - Does anything in the case body read the case value after the subtest function returns — a goroutine that is not waited for, a registered callback, a stored function? Under old semantics that is the bug; under new semantics it is fine, but an unwaited goroutine in a test is its own problem. - Does the repository's `go.mod` declare 1.22 or later? That single line decides whether the shadow is redundant. - If a suite passes suspiciously fast or every case fails with the same input echoed in the message, suspect that every row is seeing one case's data.
- Why was a plain t.Run(tc.name, func(t *testing.T){...}) safe even before Go 1.22?t.Run invokes the function and blocks until it returns before the loop advances, so the shared tc still holds the current row for the whole life of the closure. The bug needed a use of tc that outlived the iteration, such as a goroutine nobody waits for or a callback stored for later.
- How would you tell whether tc := tc can be deleted from an old test file?Read the go line in the module's go.mod. If it declares 1.22 or later, per-iteration loop variables are already in effect and the shadow is redundant. If it declares an older version, the module still compiles under the old rules and removing the line can reintroduce the bug.
- What does this bug look like in CI rather than in the source?It usually looks like success. Every row exercises the last case's input, so a forty-row table covers one input forty times and stays green. The other tell is a run where every case fails with the same input echoed in the message, which is why failure messages should always print the input.
saying these in an interview costs you the question
- Says Go closures capture a copy of the variable's value
- Thinks the loop variable fix was about the testing package
- Claims installing a newer toolchain changes the loop semantics
- Believes plain t.Run was broken before 1.22
- Cannot explain what tc := tc actually declares