Parallel Tests in Go and the Shared State Bugs They Expose
Use t.Parallel in Go tests safely: what runs in parallel, why parallel tests on a shared key fail, the t.Setenv panic and what the race detector reports.

t.Parallel is a one-line change that makes a test suite faster and, at the same time, makes it a better detector of shared state. Both effects are useful, and the second is the one that surprises people: tests that passed for years start failing when two of them run at once. The tests were never independent. They only looked it because they ran one after another.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article shows what t.Parallel does and does not do on Go 1.25.6, with three failures that come from it, so you recognise them when they happen to you.
What t.Parallel means
A test function that calls t.Parallel() signals that it can run concurrently with other tests that do the same. The testing package pauses it at that point. When all the sequential tests in the package have finished, the parallel ones are released together, and at most -parallel of them run at a time. The flag defaults to GOMAXPROCS, so on a ten core machine up to ten parallel tests run at once.
Three consequences are worth knowing.
- Order is lost. A parallel test starts after the sequential ones, and the order among parallel tests is not defined.
- Subtests of a parallel parent are parallel among themselves only if they call
t.Paralleltoo. The parent waits for its subtests before it finishes. - Parallelism is within a package.
go test ./...also runs different packages in parallel, controlled by-p, as separate processes.-paralleldoes not affect that.
Parallel tests only help when the tests wait on something: I/O, a time.Sleep, a container. A suite of pure CPU tests gains up to the core count, and a suite of tests that block on setup gains more. The slow suite article measures it.
The lab
package main
import "sync"
var (
mu sync.Mutex
hits = map[string]int{}
total int
)
// Hit records a visit. The unlocked total is the shared state parallel tests trip over.
func Hit(page string) {
mu.Lock()
hits[page]++
mu.Unlock()
total++
}
func Hits(page string) int {
mu.Lock()
defer mu.Unlock()
return hits[page]
}
func main() {}package main
import (
"fmt"
"testing"
)
func TestSequentialPages(t *testing.T) {
for _, p := range []string{"a", "b", "c"} {
Hit(p)
if got := Hits(p); got != 1 {
t.Errorf("%s: %d", p, got)
}
}
}
func TestParallelDistinctKeys(t *testing.T) {
for i := range 4 {
page := fmt.Sprintf("page-%d", i)
t.Run(page, func(t *testing.T) {
t.Parallel()
Hit(page)
if Hits(page) != 1 {
t.Errorf("%s was hit by someone else", page)
}
})
}
}
func TestParallelSameKey(t *testing.T) {
for i := range 4 {
t.Run(fmt.Sprint("hit", i), func(t *testing.T) {
t.Parallel()
Hit("shared")
// Asserting on the exact count only works if nobody else touches the key.
if got := Hits("shared"); got != 1 {
t.Errorf("saw %d hits", got)
}
})
}
}
func TestSetenvParallel(t *testing.T) {
t.Parallel()
defer func() { fmt.Println("recovered:", recover()) }()
t.Setenv("MODE", "test")
}
func TestParallelTotal(t *testing.T) {
for i := range 4 {
t.Run(fmt.Sprint("w", i), func(t *testing.T) {
t.Parallel()
for range 2000 {
Hit("busy")
}
})
}
}F='s/ \([0-9.]+s\)//; s#^ +[a-z_]+\.go:[0-9]+: # #; s/0x[0-9a-f]+/0xADDR/g; s#/[^ ]*/(shared[a-z_]*\.go)#\1#'
echo "# plain"
go test -count=1 -v -run 'Sequential|DistinctKeys|Setenv' . 2>&1 | grep -E '^(--- | --- |recovered|ok)' | sed -E "$F; s/\t[0-9.]+s//"
echo
echo "# same key, parallel"
go test -count=1 -run SameKey . 2>&1 | grep -E 'saw|^FAIL|^ok' | sed -E "$F; s/\t[0-9.]+s//" | sort | uniq -c
echo
echo "# the race detector on the unlocked total"
go test -race -count=1 -run 'ParallelTotal' . 2>&1 | grep -E 'WARNING: DATA RACE|shared.go:16|race detected' | sed -E "$F; s/^ +//" | sort -u# plain
--- PASS: TestSequentialPages
--- PASS: TestParallelDistinctKeys
--- PASS: TestParallelDistinctKeys/page-0
--- PASS: TestParallelDistinctKeys/page-3
--- PASS: TestParallelDistinctKeys/page-2
--- PASS: TestParallelDistinctKeys/page-1
recovered: testing: test using t.Setenv or t.Chdir can not use t.Parallel
--- PASS: TestSetenvParallel
ok example.test/lab
# same key, parallel
1 saw 2 hits
1 saw 3 hits
1 saw 4 hits
2 FAIL
1 FAIL example.test/lab
# the race detector on the unlocked total
WARNING: DATA RACE
race detected during execution of test
shared.go:16 +0xADDRHit increments a counter per page under a mutex, and also a global total without one. That asymmetry is deliberate: it is the shape of real code where most of the shared state is protected and one variable is forgotten.
Independent data passes
TestParallelDistinctKeys starts four subtests, each calling t.Parallel, each hitting a page of its own. They all passed, and the verbose output lists the subtests in a different order from the source. That is the model to follow: each parallel test owns the state it touches. A key derived from the test name, a table row in its own schema, a file in t.TempDir(), a port from :0, a fresh instance of the type under test.
A detail of the loop: in the file, each iteration's subtest closure uses page and i. Since Go 1.22 every loop iteration has its own copy of the loop variable, so the closure sees the right one. On a module with go 1.21 or earlier in go.mod, the old rule applies, all four closures would share one variable, and you need page := page before the t.Run. Check the go line in go.mod, not the compiler version.
A shared key fails
TestParallelSameKey runs four subtests that all hit the page "shared" and assert that the count is exactly 1. In the recorded output the failures read saw 2 hits, saw 3 hits and saw 4 hits. One subtest saw 1, and the rest saw the others' increments. Which subtest got the lucky 1 differs from run to run.
The assertion is the bug. A test that checks an absolute value on state other people write to is wrong as soon as it is parallel. Two ways out: give each test its own key, or assert on a change relative to the start (before := Hits(k), act, Hits(k) == before+1). The second is weaker, since concurrent increments from other tests make it fail too, so the first is the one to prefer.
This is also what makes parallel tests a test of the tests. A suite that passes with t.Parallel on every test has proved that no test depends on another's side effects. Teams sometimes add t.Parallel across the suite for that reason as much as for the speed.
The panic with t.Setenv
TestSetenvParallel calls t.Parallel() and then t.Setenv. Setting an environment variable changes the whole process, so two parallel tests that set the same variable would see each other's values. The testing package refuses: the call panics, and the recovered message is testing: test using t.Setenv or t.Chdir can not use t.Parallel. The lab recovers it to print the text. In a real test you would see the panic fail the test.
The same restriction applies to t.Chdir, introduced in Go 1.24, for the same reason: the working directory is process-wide. A test that needs a particular environment should either not be parallel, or be refactored so the code under test takes its configuration as a parameter and no longer reads the environment. The latter is the better design, and the setenv article shows how to get there.
The variable nobody locked
TestParallelTotal runs four parallel subtests, each calling Hit("busy") 2000 times. There is no assertion on the count, because the interesting thing is what -race reports. The run printed WARNING: DATA RACE once per detected pair, pointing at shared.go:16, which is total++. The mutex protects the map, and total was outside it, so two goroutines incremented it concurrently.
The point is how it was found. The sequential suite never ran two Hit calls at once, so the race was invisible. Parallel tests created the overlap, and the detector did the rest. If you have a suite with few parallel tests and a lot of shared code, adding t.Parallel and -race is one of the cheapest ways to find races in code you did not write for concurrency. The race detector article covers the report and the CI setup.
When not to use it
- Tests that mutate package state you cannot give each test its own copy of: a global logger, a registry, a feature flag. Leave them sequential or change the design.
- Tests that bind fixed ports, write to fixed paths or use one shared external resource such as a single database schema. Use dynamic ports and per-test schemas, or do not parallelise.
- Tests dominated by process-wide effects:
os.Chdir, signal handling,os.Stdoutredirection,timestubs set through a package variable. - Benchmarks.
t.Parallelis for tests.b.RunParallelis the benchmark tool and it works differently.
A sequential test among parallel ones costs nothing: it just runs first. There is no obligation to parallelise everything, and a half-parallel suite where the parallel tests are the independent ones is a good result. For the standard documentation, see the testing.T.Parallel reference and the Go blog's subtests and sub-benchmarks article, which introduced the grouping behaviour.


