Parallel Tests Within a Package
Go gives you parallelism at the package level almost for free: go test ./... distributes packages across available CPUs. Within a package, though, tests run sequentially by default, which is fine until one package grows large. A big API package with roughly 200 tests, for instance, can become the slowest part of a test run.
That's where t.Parallel() comes in. It marks individual tests as eligible to run concurrently with other parallel-marked tests inside the same package. Adding it to that large package cut a single run's time by 30-40%, and made ten consecutive runs 2-3x faster:
$ go test ./server/api -count=1
ok github.com/crunchydata/priv-all-platform/server/api 1.486s
$ go test ./server/api -count=10
ok github.com/crunchydata/priv-all-platform/server/api 11.786s
$ go test ./server/api -count=1
ok github.com/crunchydata/priv-all-platform/server/api 0.966s
$ go test ./server/api -count=10
ok github.com/crunchydata/priv-all-platform/server/api 3.959s
The pattern is simple to adopt: annotate every test with t.Parallel(), then enforce it going forward with the paralleltest linter. For a new project, this is nearly free. For an existing one, retrofitting requires fixing any parallelism hazards that surface, but it only gets more expensive later.
Is This Universal Practice?
Not really. Go's own test suite only uses t.Parallel() in roughly a tenth of its tests:
# total number of tests
$ ag --no-filename --nobreak 'func Test' | wc -l
7786
# total number of uses of `t.Parallel()`
$ ag --no-filename --nobreak 't\.Parallel\(\)' | wc -l
620
That's understandable. Package-level parallelism is often sufficient, and small packages may not benefit from intra-package parallelism at all—the overhead can make them trivially slower. Still, annotating tests from the start avoids a costly migration if a package grows into a bottleneck.
Sharp Edges
Shared Database With Test Transactions
The first obstacle is usually the test database. Parallel tests that insert conflicting data into the same tables will interfere with each other. One solution is to run each test inside its own transaction and roll it back when the test finishes:
func TestTx(ctx context.Context, t *testing.T) pgx.Tx {
tx, err := getPool().Begin(ctx)
require.NoError(t, err)
t.Cleanup(func() {
err := tx.Rollback(ctx)
if !errors.Is(err, pgx.ErrTxClosed) {
require.NoError(t, err)
}
})
return tx
}
The helper can share a package-level connection pool, since pools are parallel-safe. A mutex is still useful to guarantee a single initialization:
var (
dbPool *pgxpool.Pool
dbPoolMu sync.RWMutex
)
Usage stays clean with Go's Cleanup hook:
tx := TestTx(ctx, t)
Deadlocks Across Transactions
A subtler issue arises with Postgres upserts. Seeding known resources via an upsert in each test can deadlock, even with per-test transactions, when parallel tests try to upsert identical rows:
plan := dbfactory.Plan_AWS_Hobby2(ctx, t, tx)
func Plan(ctx context.Context, t *testing.T, e db.Executor, opts *PlanOpts) *dbsqlc.Plan {
validateOpts(t, opts)
configPlan := providers.Default.MustGet(opts.ProviderID).MustGetPlan(opts.PlanID, true)
plan, err := dbsqlc.New(e).PlanUpsert(ctx, dbsqlc.PlanUpsertParams{
CPU: int32(configPlan.CPU),
Disabled: configPlan.Disabled,
DisplayName: configPlan.DisplayName,
Instance: configPlan.Instance,
Memory: configPlan.Memory,
ProviderID: opts.ProviderID,
PlanID: configPlan.ID,
Rate: int32(configPlan.Rate),
})
require.NoError(t, err)
return &plan
}
The fix is to move away from per-test seeding. Load shared fixture data once when the test database is created, alongside schema and migrations, so tests only look up the data they need:
.PHONY: db/test
db/test:
psql --echo-errors --quiet -c '\timing off' -c "DROP DATABASE IF EXISTS platform_main_test WITH (FORCE);"
psql --echo-errors --quiet -c '\timing off' -c "CREATE DATABASE platform_main_test;"
psql --echo-errors --quiet -c '\timing off' -f sql/main_schema.sql
go run ./apps/pmigrate
go run ./tools/src/seed-test-database/main.go
func Plan(ctx context.Context, t *testing.T, e db.Executor, opts *PlanOpts) *dbsqlc.Plan {
validateOpts(t, opts)
_ = providers.Default.MustGet(opts.ProviderID).MustGetPlan(opts.PlanID, true)
// Requires test data is seeded.
provider, err := dbsqlc.New(e).PlanGetByID(ctx, dbsqlc.PlanGetByIDParams{
PlanID: opts.PlanID,
ProviderID: opts.ProviderID,
})
require.NoError(t, err)
return &provider
}
Logging and t.Log
Tests that log to stdout directly worked fine sequentially, but parallel execution turns the output into interleaved noise, especially when diagnosing a failure. The solution is to route logs through t.Logf, which collates messages per test case. This typically requires a small shim for your logging library:
// tlogWriter is an adapter between Logrus and Go's testing package,
// which lets us send all output to `t.Log` so that it's correctly
// collated with the test that emitted it. This helps especially when
// using parallel testing where output would otherwise be interleaved
// and make debugging extremely difficult.
type tlogWriter struct {
tb testing.TB
}
func (lw *tlogWriter) Write(p []byte) (n int, err error) {
// Unfortunately, even with this call to `t.Helper()` there's no
// way to correctly attribute the log location to where it's
// actually emitted in our code (everything shows up under
// `entry.go`). A good explanation of this problem and possible
// future solutions here:
//
// https://github.com/neilotoole/slogt#deficiency
lw.tb.Helper()
lw.tb.Logf((string)(p))
return len(p), nil
}
With a logger like Logrus:
func Logger(tb testing.TB) *logrus.Entry {
logger := logrus.New()
logger.SetOutput(&tlogWriter{tb})
return logrus.NewEntry(logger)
}
Failures then show their logs correctly grouped by test:
--- FAIL: TestSessionServiceCreate (0.05s)
--- FAIL: TestSessionServiceCreate/PasswordHashAlgorithmUpgrade (0.05s)
entry.go:294: time="2023-08-20T22:34:15Z" level=info msg="password_hash_line: Match result: success [account: 81b967f7-4f5c-4ab4-b1d7-3c455db35767] [hash time: 0.000694s]" account_id=81b967f7-4f5c-4ab4-b1d7-3c455db35767 hash_duration=0.000694s hash_match=true
entry.go:294: time="2023-08-20T22:34:15Z" level=info msg="sessionService: password_hash_upgrade_line: Upgraded password from \"argon2id\" to \"argon2id\" [account: 81b967f7-4f5c-4ab4-b1d7-3c455db35767] [hash time: 0.011716s]" account_id=81b967f7-4f5c-4ab4-b1d7-3c455db35767 new_algorithm=argon2id new_argon2id_memory=19456 new_argon2id_parallelism=4 new_argon2id_time=2 new_hash_duration=0.011716s old_algorithm=argon2id old_hash_iterations=0
session_service_test.go:197:
Error Trace: /Users/brandur/Documents/crunchy/platform/server/api/session_service_test.go:197
/Users/brandur/Documents/crunchy/platform/server/api/session_service_test.go:158
Error: artificial failure
Test: TestSessionServiceCreate/PasswordHashAlgorithmUpgrade
Ready-made bridges exist for common loggers, such as Slogt for slog.
goleak
If you use goleak to catch leaked goroutines, checking for leaks inside each test breaks under parallelism—parallel tests detect each other's goroutines as leaks. Replace per-test checks with goleak's TestMain wrapper:
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
Leak detection then happens once at the package level, which is sufficient if the baseline starts leak-free.
Parallel Tests Without Parallel Subtests
paralleltest by default requires t.Parallel() on subtests as well as top-level tests. That's generally good for throughput. But if a legacy test style shares mutable state across many subtests, making everything parallel-safe can be a large refactor. In that case, you can declare test-level parallelism "good enough" and turn off the subtest requirement with the ignoremissingsubtests option:
linters-settings:
paralleltest:
# Ignore missing calls to `t.Parallel()` in subtests. Top-level
# tests are still required to have `t.Parallel`, but subtests are
# allowed to skip it.
#
# Default: false
ignore-missing-subtests: true
Takeaways
Ubiquitous t.Parallel() isn't standard Go convention, but the payoff can be significant: 30-40% faster iteration on large packages. An additional benefit is that parallel runs under go test . -race can expose parallel-safety bugs that sequential runs miss—bugs that are far harder to chase down in production.
Retrofitting parallelism onto a mature suite is a substantial effort, but building it in from the start costs little and pays off as test suites grow.



