Why Do Python Tests Run Slower in CI?
Usually three causes stack up: the runner has fewer cores than your laptop, every job starts with a cold cache, and pytest runs the suite in one process.
Last verified:
Python tests run slower in GitHub Actions than on a laptop for three reasons that usually stack: the runner has fewer cores than your machine, every job starts with a cold dependency and bytecode cache, and pytest executes the suite in a single process unless you tell it otherwise. Each cause has a separate fix, and only the first one changes the per-minute rate you pay.
Answer
| Cause | What it looks like in the job log | Why it happens on a runner | Fix |
|---|---|---|---|
| Fewer cores than the laptop | Wall clock tracks test count, and one or two cores sit pinned while the suite runs | GitHub's standard hosted Linux runner for private repositories is 2 vCPU with 8GB of RAM (GitHub runner specs, checked on 2026-08-13) | Point runs-on at a larger label, for example warp-ubuntu-latest-x64-8x at 8 vCPU and 32GB (cloud runners documentation) |
| Cold dependency and bytecode cache | The install step takes minutes, and first collection is slow before any test body executes | Runners are ephemeral virtual machines, freshly allocated per job and destroyed at the end (cloud runners documentation), so site-packages, the resolver cache, and every __pycache__ directory start empty | Restore the resolver cache and the virtualenv with WarpBuilds/setup-python or WarpBuilds/cache |
| Serial suite by default | One core busy on a CPU-bound suite while the rest of the machine idles | pytest runs tests in one process; parallel execution comes from the pytest-xdist plugin | Add -n <workers> with the worker count matched to the runner's vCPU count |
The order matters. Caching and worker count cost nothing per minute, so fix them before buying cores. A suite that installs from scratch and then runs on one worker will look slow on any machine, and a bigger runner makes the install cheaper only in wall-clock terms while doubling the rate.
Linux x64 shapes run from 2 vCPU with 8GB to 32 vCPU with 128GB, all on 150GB SSD storage, and every shape carries 4GB of memory per vCPU (cloud runners documentation, checked on 2026-08-13). That ratio is what decides how many pytest workers a machine can hold.
Detail
Cores: what the runner actually hands pytest
A laptop with 8 or 10 performance cores gives pytest -n auto 8 or 10 workers. GitHub's standard hosted Linux runner for private repositories carries 2 vCPU, so the same command gets 2 (GitHub runner specs, checked on 2026-08-13). For a CPU-bound suite that is the whole gap: the same tests, a quarter of the parallelism.
On warp-ubuntu-latest-x64-8x the same command gets 8 workers with 4GB each. Before changing the label, read the CPU and memory utilization the job already reports through CI observability, which groups metrics by repository, workflow, job, and instance type (observability documentation). A suite that idles at 20 percent CPU on 2 vCPU has a different problem than one that pins both cores for 15 minutes.
The caches your laptop already has
Local runs inherit state that a fresh job does not. The virtualenv is installed, wheels sit in ~/.cache/pip or ~/.cache/uv, __pycache__ holds bytecode compiled during the last run (Python compiled files reference), the database container is already up, and the page cache holds the files pytest is about to read. A runner starts with none of it.
Two mechanisms close most of that gap. WarpBuilds/setup-python installs the interpreter and caches pip, pipenv, or poetry dependencies through the cache input, and WarpBuilds/cache stores any directory as a drop-in replacement for actions/cache. For suites where environment build is the dominant cost, snapshot runners take a snapshot of the runner VM during a workflow and reuse it on later runs, which carries the installed environment forward instead of rebuilding it (snapshot runners documentation).
Worker count matched to the runner size
name: python-tests
on:
pull_request:
jobs:
pytest:
runs-on: warp-ubuntu-latest-x64-8x
steps:
- uses: actions/checkout@v4
- uses: WarpBuilds/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: requirements-dev.txt
- run: pip install -r requirements-dev.txt
- name: Run the suite with one worker per vCPU
run: pytest -n 8 --dist loadfile -q --durations=15Three details in that file do the work. The -n 8 matches the 8 vCPU of warp-ubuntu-latest-x64-8x, and -n auto reaches the same number by detecting the CPU count. The --dist loadfile mode keeps every test in a module on one worker, which stops module-scoped fixtures from being built once per worker. And --durations=15 prints the slowest tests, which is how you find out whether the remaining time is CPU work or a fixture waiting on a socket.
Pricing the parallelism decision
| Runner label | Shape | Per minute | Workers at one per vCPU | Rate per worker-minute |
|---|---|---|---|---|
warp-ubuntu-latest-x64-2x | 2 vCPU, 8GB | $0.004 | 2 | $0.002 |
warp-ubuntu-latest-x64-4x | 4 vCPU, 16GB | $0.008 | 4 | $0.002 |
warp-ubuntu-latest-x64-8x | 8 vCPU, 32GB | $0.016 | 8 | $0.002 |
warp-ubuntu-latest-x64-16x | 16 vCPU, 64GB | $0.032 | 16 | $0.002 |
warp-ubuntu-latest-arm64-8x | 8 vCPU, 32GB | $0.012 | 8 | $0.0015 |
Rates and shapes come from the cloud runners documentation and the pricing page, checked on 2026-08-13. The last column is the row's rate divided by its vCPU count, and it holds at $0.002 across the x64 ladder. Perfect scaling would therefore hold the bill flat while cutting wall clock, and the gap between that and reality is what a measurement pass is for.
Work an example. Assume a suite takes 16.0 minutes with 2 workers on warp-ubuntu-latest-x64-2x, which is $0.064 per run. Assume near-linear scaling to 8 workers lands it at 4.5 minutes on warp-ubuntu-latest-x64-8x: 4.5 minutes at $0.016 is $0.072 per run. At 500 pull request runs a month that is $32.00 against $36.00, so 11.5 minutes come off every run for $4.00 a month. Assume 16 workers reach 3.2 minutes: $0.1024 per run, or $51.20 a month, which buys 1.3 more minutes for another $15.20.
warp-ubuntu-latest-x64-8x costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner of the same 8 vCPU, 32 GB shape: 27 percent lower list price (GitHub pricing, checked on 2026-08-13).
Where more cores stop helping
Some Python suites keep their duration on any size. Tests that wait on one Postgres container serialize on the database rather than the interpreter, and adding workers turns that into lock contention. Import-heavy conftest.py files charge their cost to every worker, so 16 workers pay the import 16 times. Tests that fetch from the network run at network speed. The sizing guide for Python test suites works through reading those signals before changing a label, and the pytest sharding guide covers the other shape of the same decision, which is splitting the suite across several jobs instead of several workers. The full setup, from cache configuration to runner choice, is on the Python on GitHub Actions page.
Related Questions
How many pytest-xdist workers should I run on a GitHub Actions runner?
Start at one worker per vCPU, so 8 workers on warp-ubuntu-latest-x64-8x and 16 on warp-ubuntu-latest-x64-16x. The -n auto setting picks that number from the detected CPU count (pytest-xdist documentation). Lower it when workers contend for one database container, or when 4GB per worker runs short for a fixture set that loads large data. Past the point where one machine helps, the pytest sharding guide covers splitting the suite across jobs.
Why is the dependency install slower in GitHub Actions than on my machine?
Runners are ephemeral virtual machines, freshly allocated for each job and destroyed when the job completes (cloud runners documentation), so site-packages, the pip or uv download cache, and every __pycache__ directory start empty. Restoring those paths with WarpBuilds/setup-python or WarpBuilds/cache turns a full resolve and install into a download and unpack.
Does a larger runner cost more once the suite runs in parallel?
The Linux x64 ladder prices at $0.002 per vCPU-minute at every size, so a suite that scales linearly with worker count costs about the same per run on 8 vCPU as on 2 and returns the result sooner. Cost rises where scaling is sub-linear, which is the case worth measuring. Full rates are on the pricing page.
Do I need to change my pytest configuration to move to a bigger runner?
Change the runs-on label to a warp- label and set the worker count to match the new vCPU count. Test code, conftest files, markers, and plugins stay the same. The whole configuration, from cache setup to label choice, sits on the Python on GitHub Actions page, and the sizing guide covers picking the label from measured utilization.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.