What is schema-per-worker test isolation?
Schema-per-worker isolation is a way to run database-backed tests in parallel. Each test worker process gets its own private copy of the database structure. In PostgreSQL that copy can be a separate schema inside one database or a separate database cloned from a template. Every worker runs migrations against, reads from and writes to only its own copy, so tests on different workers cannot see or disturb each other's rows, sequences or truncations.
The technique doesn't change how individual tests are written. A test that assumed it had the database to itself in a serial run still has it to itself, because its worker owns that namespace for the whole run. The engineering work goes into the test harness: provisioning the copies quickly, routing each worker's connections to its own copy, resetting state between tests and namespacing the few resources that live outside the database schema.
Who should use it?
This approach suits teams that maintain a monolith, or any single application, with a large test suite that hits a real relational database and currently runs serially because the tests share that database. It pays off most when:
- Database-backed tests make up most of the suite's wall-clock time, and no single long test dominates.
- The tests rely on serial-era assumptions such as fixed fixture emails, predictable IDs, absolute row counts or table truncation, and rewriting them all would be expensive.
- You run the real database engine in CI and want to keep doing so rather than switching to an in-memory substitute.
- Your test runner can start multiple worker processes and expose a per-worker identifier, as pytest-xdist and Jest do.
It suits suites dominated by network calls, CPU-heavy work or a handful of unsplittable long tests less well. The first section shows how to check which case you are in. Teams whose tests already create unique data per test and never depend on global state may get enough from plain parallelism against a shared database.
Is the database really what makes your suite slow?
Schema-per-worker parallelism fits one situation well: a monolith where most of the wall-clock time goes to tests that read and write a real database, and those tests run one after another because they share that database. Before you build anything, confirm that this describes your suite.
Measure before you parallelize
Start by recording how long each test takes. Most test runners can write per-test timings to a report file, and JUnit-style XML is the common format. Sort the results and add them up by category: database-backed, pure unit, and external-service tests. If database tests account for most of the total, adding workers will help.
Next, split one-time cost from per-test cost. Creating the database, running migrations and loading seed data happen once per run. A 90-second migration step inside a 10-minute suite (illustrative estimate) will not get faster with more workers. It will repeat once per worker unless you build the schema a single time and copy it, which section 4 covers. Time the setup phase on its own.
While you run the timings, look for tests that only pass in a certain order or that depend on global state: rows left behind by an earlier test, fixed IDs, cached singletons. Running the suite in random order, serially, exposes most of them cheaply. Each one you find will break under parallel execution, so list them before you start.
When not to use this
Some suites are dominated by a few long tests that cannot be split, such as an end-to-end import that takes four minutes. In that case adding workers barely helps, because the run can never finish faster than its longest test. Rewriting or splitting those tests will save more time. Suites whose slowness comes from network calls or CPU-heavy work also get little benefit from database isolation.
import sys
import xml.etree.ElementTree as ET
from collections import defaultdict
CATEGORIES = {
"tests.db.": "database",
"tests.integration.": "external-service",
"tests.unit.": "unit",
}
def categorize(classname):
for prefix, category in CATEGORIES.items():
if classname.startswith(prefix):
return category
return "uncategorized"
totals = defaultdict(float)
tests = []
for case in ET.parse(sys.argv[1]).getroot().iter("testcase"):
seconds = float(case.get("time", 0))
classname = case.get("classname", "")
totals[categorize(classname)] += seconds
tests.append((seconds, f"{classname}::{case.get('name')}"))
grand_total = sum(totals.values()) or 1.0
for category, seconds in sorted(totals.items(), key=lambda kv: -kv[1]):
print(f"{category:18} {seconds:8.1f}s {100 * seconds / grand_total:5.1f}%")
print("\nSlowest tests:")
for seconds, test_id in sorted(tests, reverse=True)[:10]:
print(f"{seconds:8.1f}s {test_id}")
A small Python script that reads a JUnit XML report, totals test time by category so you can see whether database-backed tests dominate, and lists the slowest individual tests that would limit any gain from parallelism.
Why parallel tests against one shared database break
If your suite is dominated by database-bound tests, running them in parallel looks like an easy win. Pointing several workers at the same schema, though, turns hidden assumptions into intermittent failures. Most suites were written for serial execution, where each test could treat the database as its own.
| Failure mode | What a test assumed | What happens with shared workers |
|---|---|---|
| Unique-constraint collision | Fixture email admin@example.com is free |
Two workers insert it at once; one fails with a duplicate-key error |
| Unexpected IDs | The first inserted row gets ID 1 | Sequences are shared and not rolled back, so IDs depend on what other workers inserted |
| Truncation during use | Cleanup can wipe tables between tests | Worker A truncates orders while worker B is halfway through a test that reads them |
| Absolute row counts | count(users) == 3 after setup |
Rows from concurrent tests inflate the count |
Each of these fails only when timing lines up badly, so it shows up as a flaky test rather than a clear bug. The usual reaction is to patch tests one by one: randomize emails, assert relative counts, skip truncation. Each patch works, but the patches never stop. Every new test must follow rules that serial runs don't enforce, and a single forgotten one brings the flakiness back.
Isolating at the worker level removes the shared state itself. If each worker owns its schema, existing tests keep their serial-era assumptions and stay correct.
How does it work? The schema-per-worker model
In short, the test runner starts N worker processes. Each worker learns its own ID, turns that ID into a namespace name, makes sure the namespace exists and is migrated (usually by cloning a pre-built template), and points all its database connections at that namespace before any test runs. Tests on that worker then run serially against their private copy, while other workers do the same against theirs.
The model has three parts: a stable worker identity, a mapping from that identity to an isolated namespace, and routing that applies the mapping before any test opens a connection.
Worker identity
Most parallel runners expose an identifier for each worker process. pytest-xdist sets PYTEST_XDIST_WORKER to values like gw0 and gw1, and Jest sets JEST_WORKER_ID. The ID must be stable for the life of the worker and unique among workers running at the same time. That lets the worker reuse one schema across many tests instead of creating a new one per test. If the runner gives no ID, fall back to a fixed name for serial runs so local development behaves predictably.
Mapping and routing
Derive the namespace name from the ID with a fixed rule, such as test_gw3. You can isolate with a schema inside one database or with a separate database. With schemas, set the search path when each connection is established, usually in the pool's connect hook rather than once per session, so every pooled connection inherits it. With separate databases, rewrite the connection string before the application's database layer initializes. In both cases, routing has to happen at startup. Code that connects before your test setup runs will reach the shared default.
The routing pitfall
Routing only covers unqualified names. A raw query that hardcodes public.orders, or a migration that assumes the default schema, goes straight to shared state. These tests fail only when two workers touch the same table at the same moment, so the failures look random and disappear when you rerun the suite serially. Before you trust the setup, search the codebase for schema-qualified identifiers.
+---------------------+ +---------------------+ +---------------------+
| Worker ID: gw0 | | Worker ID: gw1 | | Raw SQL, migration |
| (from test runner) | | (from test runner) | | uses public.orders |
+----------+----------+ +----------+----------+ +----------+----------+
| | |
v v |
+---------------------+ +---------------------+ |
| Map ID -> schema | | Map ID -> schema | | bypasses
| name: test_gw0 | | name: test_gw1 | | routing
+----------+----------+ +----------+----------+ |
| | |
v v |
+---------------------+ +---------------------+ |
| Pool connect hook | | Pool connect hook | |
| SET search_path | | SET search_path | |
+----------+----------+ +----------+----------+ |
| | |
v v v
+---------------------------------------------------------------------------+
| Database (schema isolation) |
| +-----------------+ +-----------------+ +-----------------+ |
| | test_gw0 | | test_gw1 | | public (shared) | |
| +-----------------+ +-----------------+ +-----------------+ |
+---------------------------------------------------------------------------+
Each worker's stable ID maps to its own schema, applied to every pooled connection by the connect hook, while hardcoded schema-qualified queries skip that routing and reach the shared public schema.
Implementing it: template provisioning, routing and per-test reset
Run migrations once, into a template, not once per worker. With PostgreSQL you have two cloning options. CREATE DATABASE ... TEMPLATE copies a fully migrated database at file level, which is usually much faster than replaying migrations, but it fails if any session is connected to the template. Alternatively, dump the migrated schema with pg_dump --schema-only and restore it into one schema per worker, with names like test_w1 and test_w2. Name the template after a hash of the migration files. When the hash matches an existing template, reuse it. When it doesn't, rebuild. This makes caching automatic and prevents stale schemas.
Make setup idempotent. Each worker should start with DROP SCHEMA IF EXISTS ... CASCADE before cloning, because a crashed run leaves half-built schemas behind and you don't want to clean them up by hand.
Route each worker by setting search_path when the connection opens, not per query. If you set it in the pool's connect hook, every connection the worker uses is pointed at its own schema.
Wrap each test in a transaction and roll it back at teardown. This resets state almost for free, but it only covers code that stays inside that transaction. It breaks in these cases:
- Code commits explicitly. Once the commit happens, the rollback can't undo it.
- Code opens its own connections. Background jobs and pooled helpers use separate connections and can't see the test's uncommitted rows.
- Tests of cross-transaction behavior. Lock contention, deadlocks and isolation-level semantics can't be exercised inside a single transaction.
- After-commit hooks. They never fire, because nothing commits.
Mark these tests explicitly. Let them commit for real, then clean up by truncating only the tables they touched with TRUNCATE ... RESTART IDENTITY CASCADE. Truncating every table after every test would cost far more time.
#!/usr/bin/env bash
set -euo pipefail
WORKERS="${WORKERS:-4}"
export PGHOST=localhost PGUSER=app_test PGPASSWORD=example-only-password
HASH=$(find db/migrations -type f -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -c1-12)
TEMPLATE="test_tpl_${HASH}"
db_exists() {
psql -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = '$1'" | grep -q 1
}
if ! db_exists "$TEMPLATE"; then
# Build under a temporary name. A crash mid-migration leaves only *_building behind,
# never a half-migrated database under the real template name.
BUILD="${TEMPLATE}_building"
dropdb --if-exists "$BUILD"
createdb "$BUILD"
# Placeholder: run your app's migration tool against the build database.
DATABASE_URL="postgres://${PGUSER}@${PGHOST}/${BUILD}" ./bin/migrate
# The rename only happens after migrations succeed.
psql -d postgres -c "ALTER DATABASE \"${BUILD}\" RENAME TO \"${TEMPLATE}\""
fi
for i in $(seq 1 "$WORKERS"); do
dropdb --if-exists "test_w${i}"
createdb -T "$TEMPLATE" "test_w${i}"
done
echo "Cloned ${WORKERS} worker databases from ${TEMPLATE}"
An idempotent setup script that runs migrations once into a PostgreSQL template named after a hash of the migration files, reuses the template on later runs, and gives each worker a fresh copy with createdb -T instead of migrating each worker separately.
State that leaks outside the schema
A schema boundary only isolates objects that live inside a schema. Anything outside it stays shared, and parallel workers will collide on it in ways that show up as intermittent failures rather than clean errors.
At the database level, roles are cluster-wide. Extensions and settings applied with ALTER DATABASE or ALTER SYSTEM affect every session in the database or cluster. LISTEN/NOTIFY channels are also database-wide, so one worker's notification can wake another worker's listener. Outside the database, the same problem applies to anything addressed by a fixed name: queue names, cache keys, upload directories and listening ports.
The general fix is to derive every external name from the worker ID your test runner exposes. Use the same value you already use to pick the schema, so a single identifier ties all of a worker's resources together.
| Shared resource | Why it collides | Per-worker fix |
|---|---|---|
| Job queue | Worker A's job runs against worker B's schema | Suffix queue names with worker ID |
| Cache | Same key, different fixture data | Prefix every key with worker ID |
| File storage | Overwrites and cleanup deleting others' files | Separate temp root per worker |
| Network ports | Bind fails or hits the wrong process | Bind to port 0, read back the assigned port |
| Roles, extensions, DB settings | Cluster- or database-wide | Serialize, or use a dedicated database |
Some tests can't be namespaced. Tests that create roles, change database settings, install extensions or exercise migrations change state for every worker at once. Tag them and run them in a serial phase after the parallel run. If there are many of them, give them a throwaway database of their own.
import os
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler
import pytest
WORKER = os.environ.get("PYTEST_XDIST_WORKER", "main")
def pytest_configure(config):
config.addinivalue_line(
"markers", "serial: changes roles, extensions or DB settings; run after the parallel phase"
)
@pytest.fixture(scope="session")
def worker_names():
# One identifier derives every external name, so a worker's resources stay together.
return {
"schema": f"test_{WORKER}",
"queue": f"default_{WORKER}", # jobs never land in another worker's queue
"cache_prefix": f"{WORKER}:", # prepend to every cache key
}
@pytest.fixture(scope="session")
def storage_root(tmp_path_factory):
# Uploads and cleanup stay inside this worker's own directory.
return tmp_path_factory.mktemp(f"uploads_{WORKER}")
@pytest.fixture
def stub_server():
# Port 0: the OS picks a free port; read it back instead of hard-coding one.
server = HTTPServer(("127.0.0.1", 0), BaseHTTPRequestHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
yield server.server_port
server.shutdown()
server.server_close()
A pytest-xdist conftest that derives schema, queue, cache prefix and storage directory from one worker ID, binds test servers to port 0, and registers a serial marker so tests that touch cluster-wide state run after the parallel phase.
Choosing worker count, balancing load and rolling it out
Split work by recorded duration, not file count. Most runners can persist per-file or per-test timings from a previous run and use them to assign shards. Splitting by count puts the one 90-second integration file and forty fast unit files on equal footing, and the run finishes only when the unluckiest worker does.
Choose worker count from two limits: CPU cores on the test host, and the database's connection ceiling divided by the connections each worker opens, including pool size and background jobs. Past some point the database server saturates on I/O, locks or catalog work, and extra workers make runs slower. Measure a few counts and keep the fastest, not the highest.
Illustrative estimate. Assume 20 minutes of serial test time, 60 seconds of fixed setup (boot, template clone), and a slowest file of 90 seconds:
- 8 workers: 60 + max(1200/8, 90) = 210 s
- 16 workers: 60 + max(75, 90) = 150 s
Doubling the workers saves only a minute. After that, setup and the slowest file set the floor, so splitting that file pays off more than adding workers.
For rollout, budget a week or two of flaky failures. Parallelism exposes hidden order dependencies. Add a CI job that runs tests in random order and logs the seed so failures can be reproduced. On a schedule, rebuild the schema through every migration from scratch so a cached template cannot hide a broken migration. Locally, default to a modest worker count and provide a single flag or environment variable that forces serial mode for debugging.
Alternatives and impact on cost and engineering time
Schema per worker is one option among several. Each alternative trades isolation against setup effort and CI resources differently.
| Approach | Isolation | Setup effort | CI load | Main risk |
|---|---|---|---|---|
| Schema per worker | Strong within one server | Moderate | One server, many connections | State outside the schema leaks |
| Database per worker | Stronger: catalogs, extensions | Moderate | Similar to schemas, more catalog overhead | Slower provisioning if not templated |
| Container per worker | Full, including server settings | Higher | Memory and startup time multiply per worker | Runner capacity limits worker count |
| Shared database, unique test data | Weak; relies on discipline | Low at first | Lowest | Flaky tests from aggregate queries and global tables |
| In-memory or substitute database | Strong, but against a different engine | Low to moderate | Low | Passes tests that fail in production |
| Rewrite or mock slow tests | Not applicable | High, spread over time | Lower | Mocks drift from real queries |
These are not mutually exclusive. Mocking hot paths still helps after you parallelize. Parallelizing also gives you time to rewrite tests gradually instead of all at once.
The costs are real. You pay for the initial setup. You maintain two classes of tests: fast ones that use transaction rollback and slower ones that need truncation because they commit or span connections. You also need a larger CI database. Compare those costs with waiting time:
Illustrative estimate (assumed numbers, not a benchmark)
Hours saved per week = runs per week × minutes saved per run × share of runs someone waits on ÷ 60
Assume 200 runs, 10 minutes saved per run, and someone waiting on half of the runs: 200 × 10 × 0.5 ÷ 60 ≈ 17 engineer-hours per week. Compare that with your own estimate of setup effort plus ongoing maintenance.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to parallel database tests monolith, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.