# Speed up pytest in CI

> Speed up slow CI test jobs: cache dependencies with uv or pip, use pytest-xdist on every core, shard with pytest-split, migrate once, and see why CI is slower than local.

A test job in CI does much more than run tests. It checks out the code, installs Python and every dependency, starts a database, builds the test database, collects the tests, runs them (often with coverage), and uploads reports. On a cold machine with fewer CPUs than your laptop. Each of those steps can be made faster, and the biggest win is rarely where people look first.

## Measure first

Look at the step timings of a recent run before changing anything. If installing dependencies takes 2 minutes and the tests take 3, caching is worth as much as any test optimization.

For the test step itself, profile it in CI, not only locally, because the cause is often different there:

```yaml
- name: Run tests
  run: pytest --flakytest-profile
  env:
    FLAKYTEST_SECRET_TOKEN: ${{ secrets.FLAKYTEST_SECRET_TOKEN }}
```

With the token set, every CI job uploads its profile to flakytest.dev and becomes its own environment. Without it, the report is written to `.flakytest/runs/`; save that directory as a build artifact to keep it. See [configure your CI](https://flakytest.dev/ci/) for adding the token as a secret.

## Cache dependencies

Downloading and building the same wheels on every run is pure waste. With uv, `astral-sh/setup-uv` caches uv's cache directory between runs, keyed on your lock file:

```yaml
steps:
  - uses: actions/checkout@v7
  - uses: astral-sh/setup-uv@v10.3.0
    with:
      enable-cache: true
  - run: uv sync --locked
  - run: uv run pytest
```

With pip, `actions/setup-python` does the same for pip's cache:

```yaml
steps:
  - uses: actions/checkout@v7
  - uses: actions/setup-python@v7
    with:
      python-version: "3.14"
      cache: pip
  - run: pip install -r requirements-dev.txt
  - run: pytest
```

The cache holds downloaded and built packages, not the installed environment, so the install step still runs; it just stops downloading and compiling. Packages that build from source (no wheel for your Python version) gain the most.

uv doesn't compile `.py` files to bytecode at install time. Python does it on first import instead, and in a fresh CI machine that's every module, every run. `UV_COMPILE_BYTECODE=1` (or `uv sync --compile-bytecode`) moves that work into the install step. Whether it's faster overall depends on how much of site-packages your tests import, so measure it.

## Use every CPU on the runner

Run the tests in parallel with [pytest-xdist](https://flakytest.dev/guides/pytest-xdist/):

```console
$ pytest -n auto
```

CI machines are smaller than laptops. GitHub's standard Linux runners have 4 CPUs for public repositories and 2 for private ones; a developer laptop often has 8 to 16. A suite that's fast locally with `-n auto` can be four times slower in CI for that reason alone. With `psutil` installed, `-n auto` uses the number of physical cores, which on a virtual machine can be lower than the vCPUs you're paying for; `-n logical` uses logical CPUs, and `PYTEST_XDIST_AUTO_NUM_WORKERS` overrides both. Try a few values and keep the fastest.

If workers finish far apart, `--dist worksteal` lets idle workers take tests from busy ones (see [unbalanced xdist workers](https://flakytest.dev/guides/pytest-xdist-worksteal/)).

## Shard across runners

When one machine isn't enough, split the suite across several CI jobs. [pytest-split](https://github.com/jerry-git/pytest-split) splits by recorded test durations, so each shard takes about the same time:

```console
$ pytest --store-durations   # writes .test_durations; commit it and refresh it now and then
```

```yaml
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        group: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v7
      - uses: astral-sh/setup-uv@v10.3.0
        with:
          enable-cache: true
      - run: uv sync --locked
      - run: uv run pytest -n auto --splits 4 --group ${{ matrix.group }} --flakytest-profile
        env:
          FLAKYTEST_SECRET_TOKEN: ${{ secrets.FLAKYTEST_SECRET_TOKEN }}
```

pytest-split picks the tests for each runner, and pytest-xdist spreads them over that runner's CPUs. Its default `duration_based_chunks` algorithm doesn't work with test-order randomization (pytest-randomly); `--splitting-algorithm least_duration` does. [pytest-shard](https://github.com/AdamGleave/pytest-shard) (`--shard-id`, `--num-shards`) is a simpler alternative that splits by hashing test ids, with no durations file, so shards are less even.

Sharding has a fixed cost: every runner checks out, installs, starts services and builds its test database again. Four shards of a 2-minute test run with 90 seconds of setup each don't finish in 30 seconds. Cut the setup first.

## Build the test database once

Building the test database by running every migration is often the most expensive thing a Django job does before its first test, and with xdist it happens once per worker, and with sharding once per runner. Migrate once and copy the result: a template database for xdist workers, a `pg_dump` restored on every runner for shards. [Run migrations once](https://flakytest.dev/guides/run-migrations-once/) shows both. flakytest.dev detects both cases from the uploaded profiles and suggests **Run migrations once, not once per worker** and **Run migrations once per CI run, not on every runner**.

The database server matters too. A PostgreSQL service container with default settings syncs every commit to disk, which tests don't need. See [make the test database server skip durability work](https://flakytest.dev/guides/fast-test-database-server/).

## Why CI is slower than your laptop

The same tests often spend their time differently in CI:

- **Fewer, slower CPUs.** Fewer xdist workers, and each one slower. CPU-bound costs like password hashing and serialization grow.
- **Cold caches.** No `.pyc` files, no reused test database (`--reuse-db` and `--keepdb` don't help on a fresh machine), no warmed-up OS file cache.
- **Different settings.** Coverage is often only on in CI; with the default tracer it slows every line (see [COVERAGE_CORE=sysmon](https://flakytest.dev/guides/faster-coverage-sysmon/)). CI settings sometimes turn on extra logging or `DEBUG`.
- **Services in containers.** The database, Redis or a browser run in containers with their own startup time and slower disks.
- **Network.** Calls a test makes to a real service are slower from a CI data center, or time out. They shouldn't happen at all (see [block network calls in tests](https://flakytest.dev/guides/block-network-in-tests/)).

flakytest.dev shows this directly. Every run is grouped by environment (`ci:github-actions:<job>` for each job, `local:<user>@<host>` for each developer machine), and the dashboard's **CI vs local** section lists the tests that are much slower in CI and the category that explains it: database, password hashing, imports, and so on. If you run on differently sized runners, set `FLAKYTEST_ENV` to keep them apart. The [MCP server](https://flakytest.dev/docs/#cloud-mcp) also has a `compare_environments` tool, so you can ask a coding agent "why is CI slower than my laptop?". See [environments](https://flakytest.dev/docs/#environments) and [in CI](https://flakytest.dev/docs/#ci) in the docs.

## Fail fast

A run that fails after 30 seconds is better than one that fails after 10 minutes.

When you push again to the same branch, the old run's results don't matter anymore. Cancel it:

```yaml
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
```

Then:

- **Run cheap checks first, or in parallel.** Linting and type checks take seconds; run them as a separate job so a formatting error doesn't wait behind the test suite.
- **Split off slow tests.** Run `pytest -m "not slow"` on every push and the slow end-to-end tests in a separate job (see [find your slowest tests](https://flakytest.dev/guides/slowest-tests/)).
- **Previous failures first.** `pytest --ff` runs the tests that failed last time first. It reads pytest's cache, so in CI it only works if you restore `.pytest_cache` between runs (for example with `actions/cache`).
- **Stop early, carefully.** `--maxfail=N` (or `-x` for 1) stops after N failures. It's useful when a broken environment fails everything, but it hides how many tests a change broke. On a matrix, GitHub's `strategy.fail-fast` (on by default) cancels the other jobs when one fails; turn it off for shards if you want the full list of failures from every shard.

## Further reading

- [setup-uv: caching](https://github.com/astral-sh/setup-uv/blob/main/docs/caching.md)
- [actions/setup-python: caching packages](https://github.com/actions/setup-python#caching-packages-dependencies)
- [pytest-xdist: running tests across multiple CPUs](https://pytest-xdist.readthedocs.io/en/stable/distribution.html)
- [pytest-split](https://github.com/jerry-git/pytest-split)
- [GitHub-hosted runners: hardware](https://docs.github.com/en/actions/reference/runners/github-hosted-runners)
- [GitHub Actions: concurrency](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency)
- [flakytest: configure your CI](https://flakytest.dev/ci/)
