When a pytest job is slow on CircleCI, the instinct is often to use a larger resource class or raise parallelism. For many Django and Postgres suites that use pytest-xdist, that can make the problem worse: the expensive work is frequently building the same test database N times per node, not the assertions themselves.
This is because pytest-django’s default with xdist is to give each worker its own database (for example test_foo_gw0, test_foo_gw1), so isolation stays correct for transactional tests. Raising -n (or packing more concurrent workers onto a larger machine) therefore multiplies migrate/seed work against the same Postgres instance unless that setup is shared via cloning or caching. See also Running tests in parallel with pytest-xdist.
Two complementary patterns remove that waste:
Postgres TEMPLATE cloning: migrate and seed once per CircleCI node; each xdist worker clones with CREATE DATABASE … TEMPLATE (seconds instead of minutes).
CircleCI dump cache: pg_dump that template and restore_cache / pg_restore on later pipelines when migrations and seed inputs are unchanged.
In a production-scale suite after a comparable change (same resource class and parallelism throughout):
Metric (success runs, day-over-day)
Before
After
Change
Median job duration
~18.5 min
~12.5 min
−33%
Mean compute credits / run
~1,370
~1,040
−24%
Median CPU util
75%
61%
−14 pp
Median RAM util
77%
50%
−27 pp
Resource class / parallelism
arm.large × 3
same
unchanged
Max CPU stayed near ~98% on short bursts. The job got faster and cooler on medians, not closer to the resource ceiling. This provided evidence that the setup work was removed, not that the machine was undersized.
Who is this for?
Teams running large pytest suites (especially Django + pytest-xdist) against Postgres on CircleCI
Platform / CI engineers who see long gaps after collection before the first assertion
Anyone scaling resource class or -n without shrinking that early setup window
Prerequisites
A CircleCI job with a Postgres sidecar (or equivalent reachable Postgres)
Familiarity with pytest and (optionally) pytest-xdist
Ability to fingerprint migrations / seed inputs for cache keys
Recognize the anti-pattern
Typical layout on each CircleCI node:
CircleCI parallelism (split files across nodes)
└─ Docker job + Postgres sidecar
└─ pytest -n N (xdist workers gw0…gwN-1)
└─ on first DB use: EACH worker CREATE DATABASE + migrate + seed
Isolation is good. Paying full migrate+seed N times against one Postgres is not.
Symptoms
Long gap after collection before the first assertion
CircleCI CPU/RAM charts show a sustained climb for several minutes, then a flatter test phase
Scaling resource class or -n does not shrink that early window (and often lengthens contention)
Logs show multiple workers applying the same migrations in parallel
collection_* → first_test_setup → db_setup_start / db_setup_finish → first_test_call → suite end
Phase
Where to emit
collection_start / collection_finish
pytest_collection hook
first_test_setup
first pytest_runtest_setup on that worker
db_setup_start / db_setup_finish
around migrate+seed (or TEMPLATE clone) on first DB use
first_test_call
first pytest_runtest_call on that worker
Where to configure: add a small helper module and wire it from the root conftest.py (or a dedicated pytest plugin registered via pytest_plugins). Call db_setup_* in the same place the suite already creates the worker database — often the first pytest_runtest_setup, or a custom override of pytest-django’s django_db_setup — not in pytest_sessionstart if migrate is lazy on first test use. That lazy path is what makes the gap after collection look like unexplained resource burn.
Illustrative helper (including hook sketch in comments):
"""Illustrative CI_TIMING helpers for correlating pytest phases with CircleCI graphs.
Emit one line per phase to job stdout (or a shared log file). Overlay ``elapsed_ms``
on the job's CPU/RAM chart: a long climb between ``db_setup_start`` and
``db_setup_finish`` means migrate/seed dominates wall time.
Wire emits from a pytest plugin or root ``conftest.py`` (see comments below).
"""from__future__importannotationsimportosimporttimefromdatetimeimportdatetime,timezone_SESSION_START=time.monotonic()_FIRST_TEST_SETUP_EMITTED=False_FIRST_TEST_CALL_EMITTED=Falsedefworker_label()->str:returnos.environ.get("PYTEST_XDIST_WORKER")or"controller"defemit(phase:str,**fields:object)->None:"""Print a single structured line (also append to CI_TIMING_LOG if set)."""elapsed_ms=int((time.monotonic()-_SESSION_START)*1000)wall=datetime.now(timezone.utc).isoformat()parts=["CI_TIMING",f"phase={phase}",f"elapsed_ms={elapsed_ms}",f"pid={os.getpid()}",f"worker={worker_label()}",f"wall={wall}",]forkey,valueinfields.items():parts.append(f"{key}={value}")line=" ".join(parts)+"\n"# Optional shared file: under pytest-xdist + --capture=fd, worker stdout is# often not forwarded; append + tail -F in the job step so phases appear in# CircleCI logs. Example: export CI_TIMING_LOG=/tmp/ci-timing.logpath=os.environ.get("CI_TIMING_LOG")ifpath:withopen(path,"a",encoding="utf-8")asfh:fh.write(line)fh.flush()else:print(line,end="",flush=True)# --- Where to call emit (pytest hooks / DB setup) ----------------------------## Put this module on PYTHONPATH and register hooks in conftest.py or a plugin:## # conftest.py# import pytest# from ci_timing import emit, emit_first_test_setup_once, emit_first_test_call_once## @pytest.hookimpl(wrapper=True)# def pytest_collection(session):# emit("collection_start")# result = yield# emit("collection_finish", num_items=len(session.items))# return result## @pytest.hookimpl(tryfirst=True)# def pytest_runtest_setup(item):# emit_first_test_setup_once()# # Call migrate/seed (or TEMPLATE clone) once per worker here, wrapping:# # emit("db_setup_start")# # ... build DB ...# # emit("db_setup_finish", migrate_ms=...)## @pytest.hookimpl(hookwrapper=True)# def pytest_runtest_call(item):# emit_first_test_call_once()# yield## Do not migrate in pytest_sessionstart if the suite lazily builds the DB on# first test use — that is what makes the gap after collection look like# unexplained CPU/RAM burn on CircleCI.defemit_first_test_setup_once()->None:global_FIRST_TEST_SETUP_EMITTEDif_FIRST_TEST_SETUP_EMITTED:return_FIRST_TEST_SETUP_EMITTED=Trueemit("first_test_setup")defemit_first_test_call_once()->None:global_FIRST_TEST_CALL_EMITTEDif_FIRST_TEST_CALL_EMITTED:return_FIRST_TEST_CALL_EMITTED=Trueemit("first_test_call")
Reading the result on CircleCI: open the job → Resources (CPU/RAM) and search step logs for CI_TIMING. Overlay elapsed_ms on the graph: a multi-minute climb between db_setup_start and db_setup_finish (after collection_* / first_test_setup) means setup dominates. With pytest-xdist and --capture=fd, worker stdout may not reach the controller — set CI_TIMING_LOG and tail -F that file in the test step so worker phases appear in the job log.
Rule of thumb: if migrate+seed on the critical path is ≥ ~2–3 minutes, TEMPLATE is worth trying. If that cost repeats every pipeline with the same schema/fixtures, add dump caching.
CircleCI Insights and Usage
Use Insights (or the CLI) for median job duration over a date range. A step-change with unchanged resource class / parallelism is a strong signal that setup behavior changed.
Usage export fields such as MEDIAN_CPU_UTILIZATION_PCT and MEDIAN_RAM_UTILIZATION_PCT show whether the job is compute-bound or waiting. After a successful DB-setup optimization, expect lower median duration and lower median CPU/RAM, with max CPU still high on short peaks. Do not treat lower median CPU as under-utilization that requires a smaller class until the test phase is healthy and queues are acceptable.
Fix 1 — Migrate once, clone with Postgres TEMPLATE
Postgres can create a database by copying an existing one (template databases):
CREATEDATABASEsuite_gw0TEMPLATEsuite_template;
Build the template once per node (migrate + seed), then clone per xdist worker. Illustrative helpers:
"""Illustrative Postgres TEMPLATE helpers for pytest-xdist on CircleCI.
Build the template once per CircleCI node (migrate + seed), then each xdist
worker clones with CREATE DATABASE ... TEMPLATE so workers stay isolated
without repeating full populate.
"""from__future__importannotationsimportosimportpsycopg2fromdjango.confimportsettingsfromdjango.core.managementimportcall_commandfromdjango.dbimportconnectionfrompsycopg2.extensionsimportISOLATION_LEVEL_AUTOCOMMITdef_admin_connect_kwargs()->dict:return{"dbname":os.environ.get("ADMIN_DB","postgres"),"user":settings.DATABASES["default"]["USER"],"password":settings.DATABASES["default"]["PASSWORD"],"host":settings.DATABASES["default"]["HOST"],"port":settings.DATABASES["default"]["PORT"],}def_worker_label()->str:returnos.environ.get("PYTEST_XDIST_WORKER")or"controller"def_drop_if_exists(cur,db_name:str)->None:cur.execute("SELECT 1 FROM pg_database WHERE datname = %s",(db_name,))ifnotcur.fetchone():returncur.execute("""
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = %s AND pid <> pg_backend_pid()
""",(db_name,),)cur.execute(f'DROP DATABASE "{db_name}"')defbuild_template_database(template_name:str|None=None)->str:"""Migrate + seed once into a TEMPLATE source database."""name=template_nameoros.environ.get("DB_TEMPLATE","suite_template")conn=psycopg2.connect(**_admin_connect_kwargs())conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)cur=conn.cursor()_drop_if_exists(cur,name)cur.execute(f'CREATE DATABASE "{name}"')cur.close()conn.close()settings.DATABASES["default"]["NAME"]=nameconnection.close()connection.settings_dict["NAME"]=namecall_command("migrate",verbosity=0,interactive=False)# call_command("loaddata", ...) # or project-specific seed / factoriesconnection.close()returnnamedefensure_worker_database(*,template:str|None=None)->str:"""Create a per-worker DB (empty, or cloned from ``template``)."""db_name=f"suite_{_worker_label().replace('/','_')}"conn=psycopg2.connect(**_admin_connect_kwargs())conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)cur=conn.cursor()_drop_if_exists(cur,db_name)iftemplate:# TEMPLATE clone requires no other sessions on the template DB.cur.execute("""
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = %s AND pid <> pg_backend_pid()
""",(template,),)cur.execute(f'CREATE DATABASE "{db_name}" TEMPLATE "{template}"')else:cur.execute(f'CREATE DATABASE "{db_name}"')cur.close()conn.close()settings.DATABASES["default"]["NAME"]=db_nameconnection.close()connection.settings_dict["NAME"]=db_namereturndb_name
Wire the mode with an env var (for example DB_SETUP_MODE=full|template) in the pytest / django_db_setup hook so baseline and optimized paths can A/B in the same workflow.
Workers still get isolated databases; only construction changes.
Fix 2 — Cache the template dump across pipelines
TEMPLATE alone only helps inside one job. The template dies with the container. If migrations and fixtures are stable, cache a pg_dump and restore it on the next pipeline.
Illustrative cache helpers (fingerprint → restore or dump):
#!/usr/bin/env bash
# Illustrative helpers for caching a Postgres template dump on CircleCI.## Usage:# bash scripts/db_cache.sh fingerprint# bash scripts/db_cache.sh restore# bash scripts/db_cache.sh dump## Fingerprint every input that changes DB contents (migrations, seed knobs,# Postgres major). Prefer a pg_dump/pg_restore major that matches the sidecar.set -euo pipefail
CACHE_DIR="${DB_CACHE_DIR:-/tmp/db-cache}"DUMP="${CACHE_DIR}/template.dump"TEMPLATE="${DB_TEMPLATE:-suite_template}"mkdir -p "$CACHE_DIR"# Prefer major-matched client tools when installed beside an older default.if[[ -d /usr/lib/postgresql/15/bin ]];thenexportPATH="/usr/lib/postgresql/15/bin:${PATH}"fiexportPGHOST="${PGHOST:-localhost}"exportPGPORT="${PGPORT:-5432}"exportPGUSER="${PGUSER:-postgres}"exportPGPASSWORD="${PGPASSWORD:-}"fingerprint(){{echo"seed_profile=${SEED_PROFILE:-default}" find path/to/migrations -type f | sort |whileread -r f;do sha256sum "$f"done} > "${CACHE_DIR}/fingerprint" cp "${CACHE_DIR}/fingerprint" .db_cache_fingerprint
echo"Wrote fingerprint:" cat "${CACHE_DIR}/fingerprint"}dump(){echo"pg_dump=$(command -v pg_dump); $(pg_dump --version)" pg_dump --format=custom --file="$DUMP""$TEMPLATE"echo"CACHE_SAVED path=$DUMP"}restore(){if[[ ! -f "$DUMP"]];thenecho"CACHE_MISS reason=no_dump"return0fiecho"pg_restore=$(command -v pg_restore); $(pg_restore --version)" psql -d postgres -v ON_ERROR_STOP=1<<SQL
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = '${TEMPLATE}' AND pid <> pg_backend_pid();
DROP DATABASE IF EXISTS "${TEMPLATE}";
CREATE DATABASE "${TEMPLATE}";
SQL pg_restore --dbname="$TEMPLATE" --no-owner --no-acl "$DUMP"echo"CACHE_HIT path=$DUMP"}case"${1:-}" in
fingerprint) fingerprint ;; dump) dump ;; restore) restore ;; *)echo"usage: $0 {fingerprint|dump|restore}" >&2exit2;;esac
Critical: matching pg_dump to the server major version
pg_dumprefuses to dump a server newer than its client. If the sidecar is Postgres 15 and the primary image ships an older client, install a matching client (for example postgresql-client-15) and put it first on PATH.
End-to-end CircleCI job
Adapt paths, package installs, and fingerprint inputs to the project:
version:2.1# Example: pytest + Postgres with TEMPLATE cloning and optional dump cache.# Adapt paths, package installs, and seed/fingerprint inputs to the project.jobs:pytest-postgres:docker:- image:cimg/python:3.10- image:cimg/postgres:15.5environment:POSTGRES_USER:postgresPOSTGRES_DB:postgresPOSTGRES_HOST_AUTH_METHOD:trustresource_class:arm.largeparallelism:3environment:DB_SETUP_MODE:templateDB_TEMPLATE:suite_templateDB_CACHE_DIR:/tmp/db-cachePGHOST:localhostPGPORT:"5432"PGUSER:postgressteps:- checkout- run:name:Install Python depscommand:pip install -r requirements.txt- run:name:Wait for Postgrescommand:dockerize -wait tcp://localhost:5432 -timeout 1m- run:name:Install postgresql-client-15 (match sidecar major version)command:| # pg_dump refuses to dump a server newer than its client major.
sudo apt-get update -qq
sudo apt-get install -y -qq postgresql-client-15
export PATH="/usr/lib/postgresql/15/bin:$PATH"
pg_dump --version
echo 'export PATH="/usr/lib/postgresql/15/bin:$PATH"' >> "$BASH_ENV"- run:name:Write DB cache fingerprintcommand:| # Fingerprint migrations + anything that changes seed contents.
{
echo "seed_profile=${SEED_PROFILE:-default}"
find path/to/migrations -type f | sort | while read -r f; do
sha256sum "$f"
done
} > .db_cache_fingerprint- restore_cache:keys:- pgdump-v1-{{ checksum ".db_cache_fingerprint" }}- pgdump-v1-- run:name:Restore template dump or build template + dumpcommand:| mkdir -p "$DB_CACHE_DIR"
DUMP="$DB_CACHE_DIR/template.dump"
if [ -f "$DUMP" ]; then
psql -d postgres -v ON_ERROR_STOP=1 \<<SQL
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = '${DB_TEMPLATE}' AND pid <> pg_backend_pid();
DROP DATABASE IF EXISTS "${DB_TEMPLATE}";
CREATE DATABASE "${DB_TEMPLATE}";
SQL
pg_restore --dbname="$DB_TEMPLATE" --no-owner --no-acl "$DUMP"
echo "CACHE_HIT path=$DUMP"
else
python -c "from db_template import build_template_database; build_template_database()"
pg_dump --format=custom --file="$DUMP" "$DB_TEMPLATE"
echo "CACHE_SAVED path=$DUMP"
fi- run:name:Run pytest (workers clone template)command:| # Split files across CircleCI nodes; xdist clones DBs inside each node.
circleci tests glob 'tests/**/test_*.py' \
| circleci tests split --split-by=timings \
| xargs pytest -n 3 --dist=load- save_cache:key:pgdump-v1-{{ checksum ".db_cache_fingerprint" }}paths:- /tmp/db-cache- store_test_results:path:test-resultsworkflows:test:jobs:- pytest-postgres
Bump the cache key prefix when dump format or client assumptions change. Only cache synthetic / scrubbed test data — never production dumps with secrets or PII.
Validate with an A/B workflow
Run two jobs in the same workflow, identical except for DB setup:
Job
Mode
Baseline
Each worker: CREATE DATABASE → migrate → seed
Optimized
Once per node: populate template (or restore dump); workers: CREATE DATABASE … TEMPLATE
Hold fixed: resource class, CircleCI parallelism, xdist width, test matrix, and per-test work.
Check
Baseline
Optimized
Per-worker db_setup
minutes
~tens–hundreds of ms (clone)
Collected / passed / skipped
—
must match
Job wall
higher
lower by ~setup critical path
restore_cache (warm)
n/a
Found a cache from job …
Status line
n/a
CACHE_HIT / CACHE_SAVED
Store a one-line artifact (CACHE_HIT / CACHE_SAVED / CACHE_MISS) so reviews do not require digging through step logs.
What good looks like after rollout
Median duration drops with no resource-class / parallelism change.
Distribution may be briefly bimodal: most runs in the fast cohort, a small slow tail still paying the old cost (cold cache / non-cached path). That is expected during rollout; watch the slow-tail share fall as cache hits dominate.
Median CPU/RAM fall; max CPU stays saturated on peaks. That is consistent with removing long setup, not with needing a larger machine.
Compare concrete job URLs from before and after the flip (same branch, same job name) when explaining the change to stakeholders — duration and resource charts tell the story faster than config diffs alone.
Decision tree
Is migrate+seed ≥ ~2–3 min on the critical path?
├─ no → profile tests, deps, image pulls instead
└─ yes
Do N xdist workers each migrate against one Postgres?
├─ yes → implement TEMPLATE (Fix 1)
└─ also same schema/fixtures every pipeline?
└─ yes → add dump cache (Fix 2)
Still slow after that?
→ optimize the test phase itself (collection size, skip rate,
fixture scope, split-by-timings, flaky retries, etc.)
Safety checklist
Fingerprint all inputs that change DB contents (migrations, fixture versions, seed knobs, Postgres major)
Match pg_dump / pg_restore major version to the sidecar
Never cache dumps with production secrets or PII
Keep seed deterministic so A/B comparisons are fair