Skip to content
Chapter 88Lesson 2

One database per worker

Give Vitest's integration tests real, isolated Postgres by routing one disposable database per worker with VITEST_POOL_ID, globalSetup, and Docker or Neon.

In the last lesson you built withRollback, and every test it wraps calls db.transaction(...). That lesson set one question aside, and this lesson answers it: what real Postgres does db open a connection to? It can’t be production, because a test that can write to production is a test that can drop production. And it can’t be one shared test database either, once you remember how the runner works.

Vitest runs your test files in parallel, each in its own worker. Point them all at a single Postgres and they collide. One worker’s migration runs while another queries half-built tables. Their sequences interleave. A SELECT ... FOR UPDATE lock taken in one file blocks a query in another. The result is the worst kind of test failure: a suite that’s green on its own and red when the order shifts, with nothing in the diff to explain it.

The fix is one isolated database per worker, routed by VITEST_POOL_ID. You create the databases up front, migrate each one once, and let each worker own its database for the whole run. That picture comes first, and everything after it is plumbing in service of it. The local setup uses Docker Compose, the course default; CI keeps the exact same shape and only swaps what provides the database. By the end, pnpm vitest run --project integration will spin up isolated databases, migrate each once, and run hundreds of integration tests with zero cross-worker interference.

Last lesson gave you per-test isolation: the rollback that resets one test’s rows. This lesson gives you the per-worker isolation underneath it, the database each rollback rolls back inside.

Hold this picture before you read a line of config, because every file that follows exists to build it.

Vitest’s integration project runs each *.int.test.ts file in a pool of workers, which are OS threads by default for a Node project. A worker is long-lived: it boots once and runs many test files one after another. Each worker carries an env var, VITEST_POOL_ID , a small integer from 1 to N that names which worker a test is running on. It’s stable for the whole run: every test that lands on worker 2 reads VITEST_POOL_ID=2, start to finish.

That stable integer is the whole trick. You create databases test_w1 through test_wN once, then have each worker connect to test_w{VITEST_POOL_ID} and own it exclusively for the run. Worker 1 talks only to test_w1, worker 3 talks only to test_w3. They never share a connection, a sequence, or a lock, so they can’t interfere: not because you wrote careful cleanup, but because they’re looking at entirely different databases.

globalSetupruns once, main processVitest workersPer-worker databasesWorker 1VITEST_POOL_ID=1runs files 1..kWorker 2VITEST_POOL_ID=2runs files 1..kWorker 3VITEST_POOL_ID=3runs files 1..ktest_w1test_w2test_w3 connects toconnects toconnects to createscreatescreates

Each Vitest worker owns one database for the whole run. globalSetup creates them up front; VITEST_POOL_ID routes each worker to its own.

Why per-worker, rather than the two finer-grained options you might reach for first? Walk down the granularity:

  • A database per test means thousands of CREATE DATABASE and DROP DATABASE cycles per run, and each one is heavyweight. The suite would spend longer provisioning databases than running tests, which makes this unusable.
  • A database per file is better, hundreds of creates instead of thousands, but you’d run your full migration stack against each one. Hundreds of migration runs is still an order of magnitude too slow.
  • A database per worker lands at exactly maxWorkers migration runs, four to eight on a typical machine. Each worker migrates its database once, then reuses it across every file it runs. The per-test isolation you still need comes from withRollback inside that one database.

That last point carries the most weight, so make it explicit: there are two isolation layers, and they stack. The per-worker database isolates files from each other, since file A on worker 1 and file B on worker 3 live in separate databases. The per-test rollback from the last lesson isolates tests from each other within one worker’s database, because each test’s writes vanish before the next runs. You get coarse isolation across workers and cheap rollback isolation across tests. Together they give you full isolation at a price you can afford.

Two lifecycle scopes: globalSetup vs setupFiles

Section titled “Two lifecycle scopes: globalSetup vs setupFiles”

That picture needs two distinct kinds of setup, and conflating them is the most expensive mistake in this lesson. Get the distinction clear now and the rest of the config writes itself.

The two scopes are globalSetup and setupFiles, and they run in different places at different times:

  • globalSetup runs once, in Vitest’s main process, before the whole suite starts and again after it ends. It runs outside every worker, so it has no access to test globals and no VITEST_POOL_ID, because no worker exists yet. This is the scope for run-level, process-level work: bring up Docker, connect to Postgres as a superuser, and CREATE DATABASE test_w1 .. test_wN. On teardown, optionally drop them.
  • setupFiles runs once per worker, inside the worker, before that worker’s test files. The worker’s environment is live here, so VITEST_POOL_ID is set and readable. This is the scope for per-worker work: connect to this worker’s database, run migrations against it, insert the baseline seed, and register the MSW server and the withRollback machinery.

The trap is putting a task in the wrong scope, and it fails in two directions:

  • Database creation in setupFiles means N workers all race to CREATE DATABASE test_w1 .. test_wN at once. They collide, producing database "test_w1" already exists errors or duplicate-key flakes from the catalog. Creation is a once-for-the-run job, so it belongs in globalSetup, where exactly one process does it before any worker starts.
  • Migrations in globalSetup run against the wrong target or the wrong number of times. globalSetup has no VITEST_POOL_ID, so it can’t even tell which per-worker database to migrate. Migrations are per-worker work, so they belong in setupFiles, once inside each worker.

Here’s the whole sort in one table. Read every task as a decision about when in the run it happens exactly once:

| Task | globalSetup (once for the run) | setupFiles (once per worker) | | --- | :---: | :---: | | Bring up the Docker Postgres | ✅ | | | CREATE DATABASE test_w1 .. test_wN | ✅ | | | Connect to this worker’s database | | ✅ | | Run migrate() against the schema | | ✅ | | Insert the baseline seed org and user | | ✅ | | server.listen() for MSW | | ✅ | | Drop the test databases | ✅ | |

Now the config that wires both. You already have a root vitest.config.ts with an integration project from the runner lesson, and this adds two keys to that project: where the global setup lives, and where the per-worker setup lives.

vitest.config.ts
// inside test.projects of vitest.config.ts
{
test: {
name: 'integration',
environment: 'node',
include: ['src/**/*.int.test.ts'],
globalSetup: ['./src/test/db/global-setup.ts'],
setupFiles: ['./src/test/db/setup.ts'],
maxWorkers: 4,
},
},

One version detail matters here, because almost every tutorial you’ll find online gets it wrong. maxWorkers is a flat key on the test block, not nested under anything. Older guides bury the worker count at poolOptions.threads.maxThreads, but Vitest 4 removed poolOptions entirely and flattened it to a plain maxWorkers (the matching env override is VITEST_MAX_WORKERS). VITEST_POOL_ID is unchanged: it’s still the per-worker index, and it’s still always ≤ maxWorkers.

The two files those keys point at carry the actual work. Here they are side by side, with each one’s scope named in its first sentence.

src/test/db/global-setup.ts
import { Client } from 'pg';
const WORKER_COUNT = 4;
export default async function setup() {
const admin = new Client({ connectionString: process.env.DATABASE_URL });
await admin.connect();
for (let id = 1; id <= WORKER_COUNT; id++) {
await admin.query(`DROP DATABASE IF EXISTS test_w${id}`);
await admin.query(`CREATE DATABASE test_w${id}`);
}
await admin.end();
return async () => {
// teardown after the whole suite
};
}

Runs once, in the main process, before any worker. It connects as superuser, drops and recreates each test_w{id} database so the run starts clean, and returns a teardown that drops them at the end.

Notice the shape of the split. global-setup.ts knows nothing about any individual worker; it just makes all the databases. setup.ts knows nothing about the others; it just claims its own by VITEST_POOL_ID and gets it ready. That clean separation is exactly the split the table drew. Before going further, sort the tasks yourself.

Sort each setup task into the scope it belongs to. Ask: does it happen once for the whole run, or once inside each worker? Drag each item into the bucket it belongs to, then press Check.

globalSetup Once, in the main process
setupFiles Once per worker
Bring up the Docker Postgres
CREATE DATABASE test_w1..N
Drop the test databases at the end
Connect to this worker’s database
Run migrate() against the schema
Insert the seed baseline org and user
server.listen() for MSW

Open that per-worker setup.ts and look at the migration step, because two non-obvious details live in it.

The migration itself is the easy part, and it’s the whole reason integration tests are worth the cost. You import migrate from drizzle-orm/node-postgres/migrator and run it against the worker’s connection, pointed at the same drizzle/ folder production migrates from:

await migrate(db, { migrationsFolder: 'drizzle' });

There’s no separate test schema, no hand-written DDL, no “tables for testing.” The test database is the production schema, built from the exact same migration files that build production. The moment a migration adds a NOT NULL column or a unique constraint, your tests run against it without you touching a thing. That is what makes the schema-drift class of bug catchable here, the very bug a mocked database can never catch.

Two details turn this from “works on my machine” into “works.”

First: migrate once per worker, not once per file. A worker runs many test files, and you do not want to re-migrate before each one, which would put you right back at the per-file cost this topology was designed to avoid. The clean place is a beforeAll in setup.ts, which Vitest runs once per worker before its files, guarded so the migrate runs a single time.

Second: open the connection lazily, not at import time. This one is subtle, and it’s the bug most people hit. If setup.ts opens its Postgres pool at module-evaluation time, with a top-level const db = drizzle(...), that connection can fire before globalSetup has finished running CREATE DATABASE for the very first worker. You’d connect to a database that doesn’t exist yet and crash on the first scheduling. The fix is to open the pool lazily, inside beforeAll or behind a memoized getter, so the connection is established only after the databases are guaranteed to exist.

That second detail is worth pausing on, because it inverts advice you’ve heard everywhere else. Normally you hoist a database client to module scope and share the one singleton, which is the right shape in production. Here, the ordering across the globalSetup-then-worker boundary forces the opposite: the database has to exist before you connect, and only beforeAll runs late enough to guarantee it. The connection is declared at module scope but opened lazily.

Here’s the worker’s migrate body with both details in place.

import { beforeAll } from 'vitest';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { getWorkerDb } from './worker-db';
import { seedBaseline } from './seed';
beforeAll(async () => {
const workerId = process.env.VITEST_POOL_ID;
const db = getWorkerDb(workerId);
await migrate(db, { migrationsFolder: 'drizzle' });
await seedBaseline(db);
});

Everything runs inside beforeAll, which fires once per worker. getWorkerDb opens the pool lazily on first call, late enough that globalSetup has already created this worker’s database.

import { beforeAll } from 'vitest';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { getWorkerDb } from './worker-db';
import { seedBaseline } from './seed';
beforeAll(async () => {
const workerId = process.env.VITEST_POOL_ID;
const db = getWorkerDb(workerId);
await migrate(db, { migrationsFolder: 'drizzle' });
await seedBaseline(db);
});

VITEST_POOL_ID is the routing key. Worker 2 reads 2, getWorkerDb builds the URL for test_w2, and this worker now talks only to its own database.

import { beforeAll } from 'vitest';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { getWorkerDb } from './worker-db';
import { seedBaseline } from './seed';
beforeAll(async () => {
const workerId = process.env.VITEST_POOL_ID;
const db = getWorkerDb(workerId);
await migrate(db, { migrationsFolder: 'drizzle' });
await seedBaseline(db);
});

Migrate against the same drizzle/ folder production uses, once, for this worker. There’s no separate test schema; the test database is the production schema.

import { beforeAll } from 'vitest';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { getWorkerDb } from './worker-db';
import { seedBaseline } from './seed';
beforeAll(async () => {
const workerId = process.env.VITEST_POOL_ID;
const db = getWorkerDb(workerId);
await migrate(db, { migrationsFolder: 'drizzle' });
await seedBaseline(db);
});

After the schema is in place, insert the tiny baseline of one org and one user. The next section covers it; for now, note that it runs once per worker, right after migrate.

1 / 1

There’s a guard that belongs alongside this, and it catches a whole class of “but it passed locally” failures. Drizzle Kit has two subcommands people constantly confuse, so let’s pin the difference. migrate applies migration files to a database, which is the runtime step you just saw. check detects drift: it fails if a developer edited the schema but forgot to generate a migration, or if the migration journal and the schema disagree. Run check before the suite, and run the same check in CI:

Terminal window
pnpm drizzle-kit check

Here is what it buys you. A teammate changes a column in the schema file, runs the app (which doesn’t re-derive migrations), and pushes. Their tests pass locally only because their database was already in the new shape from some earlier manual change. check fails that push at the seam: the schema and the journal disagree, so the drift is caught before it reaches anyone else.

One baseline seed, then per-test factories

Section titled “One baseline seed, then per-test factories”

After migrate, the worker inserts a seed. The instinct here is to over-seed, to build a rich, realistic dataset so tests have something to work with. Resist it. The seed is deliberately tiny, and understanding why keeps your test setup from growing into a second app.

The baseline is one seed_org and one seed_admin_user. That’s the entire seed. They exist for a single reason: a test that doesn’t care about org or user setup still needs a valid foreign-key target to hang an invoice on. The seed gives every test a minimal, valid world to start from, and nothing more.

Both rows get fixed, well-known IDs, seed_org and seed_admin_user, exported as constants so any test can reach the baseline by name instead of querying for it.

src/test/db/seed.ts
export const SEED_ORG_ID = 'seed_org';
export const SEED_ADMIN_USER_ID = 'seed_admin_user';
export const seedBaseline = async (db: DbOrTx) => {
await db
.insert(organizations)
.values({ id: SEED_ORG_ID, name: 'Seed Org' });
await db.insert(users).values({
id: SEED_ADMIN_USER_ID,
orgId: SEED_ORG_ID,
email: 'admin@seed.test',
role: 'admin',
});
};

Heavy, realistic data is a different concern entirely, and confusing the two is the mistake. Realistic data is what drizzle-seed produces, and that’s a dev-and-demo tool for filling a database you want to look at in the app. It is not test setup. Keep the line sharp: dev seeding fills a database to look at, while test seeding gives a minimal valid baseline and lets each test build exactly the rows it asserts on.

Those per-test rows come from factories like buildInvoice({ status: 'paid' }, tx), the pattern from the unit-testing chapter, called inside the test’s transaction. That placement is the whole point. A factory row is written on tx, so withRollback discards it when the test ends. The seed baseline is different: it’s inserted in setup.ts, outside any test transaction, so it’s committed to the worker’s database and shared, read-only, across every test that worker runs.

That sharing is safe only under one rule: treat seed rows as immutable. A test reads the seed org freely, but if it mutates a seed row, that change is committed, because the seed lives outside tx. The change then leaks into every later test on that worker and reintroduces the order-dependence you came here to kill. The rule that prevents it: if a test needs to change a row, it builds its own row with a factory and mutates that. Read the seed; never write it.

So you have two kinds of test data, and they live in two different places for two different reasons. Sort them.

Sort each piece of test data into where it belongs. Ask: is it a shared, read-only baseline, or data this one test builds and asserts on? Drag each item into the bucket it belongs to, then press Check.

Baseline seed Committed once per worker, read-only
Per-test factory Built inside tx, rolls back
The seed_org every test hangs rows on
The seed_admin_user that owns the org
A paid invoice a test asserts gets archived
Three overdue invoices a reminder test scans
An invoice the test mutates and re-reads
A valid FK target a test doesn’t otherwise care about

The .env.test surface and the production-URL guard

Section titled “The .env.test surface and the production-URL guard”

Now the connection strings, the most dangerous few lines in the whole setup. Slow down here, because this is the section where a careless config doesn’t just make a test flaky, it drops production tables.

The test harness loads a dedicated .env.test, and only .env.test. Production .env and .env.local are never loaded by the test setup, and that exclusion is the first line of defense. Inside it live two values: a base DATABASE_URL pointing at the superuser the globalSetup uses to create databases, and a WORKER_DATABASE_URL_PATTERN like postgres://test:test@localhost:5433/test_w{id} that each worker fills in with its VITEST_POOL_ID to reach its own database.

Now the footgun, stated plainly because the cost is total. If .env.test points its DATABASE_URL at production, or a stray .env.local shadows it with a production URL, then the first thing your test run does is DROP DATABASE IF EXISTS and CREATE DATABASE against production, or migrate over it. The globalSetup you just wrote drops and recreates databases by design. Aim it at production and it does exactly that, to production: tables gone, data gone, on the first pnpm vitest run. This has happened to real teams, and rollback does not save you. CREATE DATABASE and migrate run outside any test transaction, so there is nothing to roll back.

The guard is cheap and structural, and it goes at the very top of globalSetup. It refuses to run at all unless the URL looks like a test target:

src/test/db/global-setup.ts
const url = process.env.DATABASE_URL ?? '';
if (!url.includes('localhost:5433') && !isNeonBranch(url)) {
throw new Error(
`Refusing to run tests against a non-test database: ${url}`,
);
}

The check asks “is this a known test target,” not “is the port literally 5433.” Read it as an allow-list: local Docker answers on localhost:5433, a Neon CI branch passes isNeonBranch, and the GitHub Actions sidecar below answers on localhost:5432. Each is a recognized disposable database, so each gets added to the allow-list as you wire that environment. What the guard refuses is the one URL that’s on no list: your production database. Match against the targets you trust, not a single magic port.

It’s a single if. It costs nothing, and it’s the difference between “my config was wrong” being a thrown error on line one rather than a postmortem. The disposability that makes this guard necessary, a database your harness freely drops and recreates, is exactly what unlocks the next trick, which is why the two belong in the same lesson.

Because the test database is disposable, you can turn off the thing Postgres works hardest to guarantee: durability. A real database flushes every commit to physical disk so a crash can’t lose acknowledged data. A test database that’s recreated on every run has nothing worth surviving a crash, so paying for that flush is pure waste. Turn it off and the database runs roughly an order of magnitude faster, which, multiplied across the migrate-and-seed of every worker plus hundreds of transactions, is real wall-clock time.

The switch is fsync off, alongside synchronous_commit=off and full_page_writes=off. The discipline that matters: this lives in the test Docker image’s config, baked into the test container, and is never set on a server holding real data. It’s the same idea as the URL guard from the other side. The disposability is what makes fsync-off acceptable and what makes the guard mandatory.

Here are the two files that define the test database: the connection surface, and the disposable, fsync-off Postgres it points at.

.env.test
DATABASE_URL=postgres://test:test@localhost:5433/postgres
WORKER_DATABASE_URL_PATTERN=postgres://test:test@localhost:5433/test_w{id}

The test connection surface, and nothing from production. The base superuser URL for globalSetup, and the per-worker pattern each worker fills in with its VITEST_POOL_ID. Production .env and .env.local are never loaded by the harness.

Three watch-outs cluster around these files, each a future debugging session:

  • A production URL in .env.test. This is the big one, covered above, and the URL guard is the insurance.
  • A port collision on 5432. If the test Postgres ran on the default 5432, it would clash with a local dev Postgres you already run. Pinning it to 5433 keeps the two side by side. When this still catches someone, it surfaces as a flaky port conflict, a flavor of flake the chapter’s flake-taxonomy lesson returns to.
  • .env.local shadowing .env.test. Env-file precedence can let a .env.local quietly override your test config. The harness loading only .env.test, never .env.local, is what stops it.

The same shape that runs locally has to run in CI, and the good news is that it really is the same shape: one Postgres, per-worker databases, migrate, run. CI just provides the Postgres differently. This is the minimal integration job. The deeper CI concerns, such as matrix builds, caching, and the JUnit reporter that renders results in the GitHub UI, belong to the later CI chapter, and the job here points forward to them rather than building them.

The default job mirrors local exactly. GitHub Actions can run a sidecar container alongside your job through services, so you declare a Postgres 17 service, install with a frozen lockfile, and run the integration project, which migrates each worker’s database itself just as it does locally:

.github/workflows/test.yml
jobs:
integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile
- run: pnpm vitest run --project integration
env:
DATABASE_URL: postgres://test:test@localhost:5432/postgres

The options block with the health-check is not boilerplate. It carries real weight, and it’s the same lesson the lazy-open taught you in a new form. Declaring a services.postgres makes the container start; it does not make it ready to accept connections. Without --health-cmd pg_isready, your migrate step races the container’s boot and fails on connection-refused, intermittently, depending on which won the race that run. The health-check makes the job wait until Postgres answers before the steps run. Existing is not the same as being ready: the worker had to wait for CREATE DATABASE, and here the job has to wait for the container. Same hazard, same fix: gate on readiness, not on existence.

One more line is worth a mention. Put if: always() on any cleanup step so a failed test run still tears down its resources instead of leaking them into the next run.

Know the three commands by when you run them:

| When | Command | | --- | --- | | Local, while you code | vitest --project integration (watch) | | CI, every push | vitest run --project integration | | Pre-push hook, only affected files | vitest run --project integration --changed |

The CI command later grows a --reporter=junit flag so GitHub can render results, and the pre-push hook is wired through a Git-hooks tool, both in the later CI and tooling chapters. For now: watch mode is for you, run is for the machine, and --changed keeps the pre-push hook fast by testing only what you touched.

When the default isn’t enough: a Neon branch per CI run

Section titled “When the default isn’t enough: a Neon branch per CI run”

The Docker services shape is the right default, and you should reach past it only when it stops paying off, so name the trigger before the tool.

The trigger is the seed step getting slow. The baseline seed is tiny by design, but some test suites genuinely need production-shaped data to assert on: real row counts that exercise pagination, or the depth of data that row-level-security policies act on, which a freshly-migrated empty database can’t provide. When your local seed-and-setup step climbs past roughly five seconds because you’re building that volume on every run, the empty-Docker default has stopped being cheap. Until then, services: postgres wins, and you should stay on it.

What you reach for then is Neon’s branching. Neon can fork a full database, schema and data, from a parent such as a staging branch, using copy-on-write : the branch shares the parent’s storage pages until something writes, so creating one is near-instant regardless of how much data the parent holds. Every CI run gets its own isolated, full-data copy of staging in under a second, runs against it, and throws it away. The per-worker shape doesn’t change at all, because you still get one logical database per worker. The only thing that swapped is the substrate underneath, from “freshly-migrated empty Docker” to “instant full-data Neon branch.”

The wiring is two actions bracketing the job. A create-branch action at job start forks the branch and exposes its connection string as an output, which you feed in as the run’s DATABASE_URL. A delete-branch action on if: always() tears the branch down when the job ends, on success or failure.

.github/workflows/test.yml
steps:
- uses: actions/checkout@v4
- uses: neondatabase/create-branch-action@v6
id: branch
with:
project_id: ${{ vars.NEON_PROJECT_ID }}
parent: staging
api_key: ${{ secrets.NEON_API_KEY }}
- run: pnpm install --frozen-lockfile
- run: pnpm vitest run --project integration
env:
DATABASE_URL: ${{ steps.branch.outputs.db_url }}
- if: always()
uses: neondatabase/delete-branch-action@v3
with:
project_id: ${{ vars.NEON_PROJECT_ID }}
branch_id: ${{ steps.branch.outputs.branch_id }}
api_key: ${{ secrets.NEON_API_KEY }}

The hinge is the create-branch step’s db_url output. ${{ steps.branch.outputs.db_url }} becomes the run’s DATABASE_URL, so every worker reaches the fresh branch instead of Docker. The companion delete step keys off the same step’s branch_id to clean up.

Two things to note. The driver may differ, since local Docker runs node-postgres while Neon CI might use the Neon serverless driver, but your test code never changes, because it talks to db and tx, not to the driver underneath them. That’s the explicit-handle seam from the last lesson paying off again. The cost is also real: Neon’s free-tier branch limits can queue concurrent CI runs against each other, which is itself the trigger to upgrade the plan, the same honest cost-accounting you apply everywhere else.

This is a decision, not a ranking. Neon isn’t “better”; it’s what you reach for when the default’s seed step gets slow. Walk the decision the way you’d ask it.

Which CI database substrate?

One practical knob to close on is how many workers to run. maxWorkers defaults sensibly, but when you set it, a good local starting point is Math.min(4, cpuCount), and CI runners with more cores can go higher.

The right number is empirical, and the topology tells you why. More workers means more databases, which means more migration runs at startup and more connections fighting over one Postgres. There’s a knee in the curve: past some point, adding workers stops buying parallelism, because the workers start contending on the database, through connection limits and lock waits, faster than the extra concurrency helps. On a laptop that knee is usually somewhere in the four-to-eight range, but the honest answer is to measure your own suite rather than memorize a number.

The reassuring part is why you can tune it freely: the per-worker-database scheme has no shared mutable state, so changing the worker count can never make a test wrong. Raise it or lower it, and the only thing that moves is throughput against contention. Correctness is fixed; the number is purely a performance dial.

Hold the whole run in one view. globalSetup runs once and drops and recreates test_w1 through test_wN. Each worker boots, reads its VITEST_POOL_ID, lazily opens a connection to its database, migrates it once against the production drizzle/ folder, and seeds one tiny baseline. Then its test files run, each test wrapped in withRollback, building its own rows with factories inside a transaction that vanishes when the test ends. Two isolation layers, stacked: databases across workers, rollback across tests.

Order those boot steps to lock the sequence in.

Order the steps of a single `vitest run --project integration`, from cold start to the first test executing. Drag the items into the correct order, then press Check.

globalSetup connects to Postgres as superuser
It creates test_w1 through test_wN
Workers start, each reading its VITEST_POOL_ID
Each worker lazily connects to its own test_w{id}
Each worker runs migrate() once against the schema
Each worker inserts its baseline seed
Tests run, each inside a withRollback transaction

And one last check on the failure with the highest stakes in this whole lesson, because it’s the one where the obvious fix is the wrong fix.

A new integration run just dropped three production tables on its very first execution, because a stray config pointed it at the production database. Which single change would have stopped it?

Have globalSetup throw on its first line unless DATABASE_URL matches a known test target like localhost:5433.
Wrap each test in withRollback so every write is discarded when the test ends.
Set fsync=on so commits are flushed to disk and nothing is lost.
Bump maxWorkers so each worker touches a smaller slice of the schema.

You wrote withRollback last lesson and gave it a database this lesson: one per worker, migrated, seeded, and guarded. The pattern now has real, isolated Postgres underneath every test. Next comes the other boundary these tests cross, the network. The same way you stopped mocking the database and ran the real query, you’ll stop mocking the SDK and intercept the real HTTP at the wire, so the serialization, signing, and parsing your code actually does stays under test.