Speed up your test suite

Run migrations once with pytest-xdist and CI shards

With pytest-xdist every worker builds its own Django test database, and every CI shard does too. Migrate once and copy it with PostgreSQL template databases or pg_dump.

Run a Django suite with pytest -n 8 and pytest-django gives each pytest-xdist worker its own test database: test_myapp_gw0 to test_myapp_gw7. Each worker builds its copy the normal way, by running every migration. Split the suite over four CI runners and each runner does the same again. The same migrations run 8 times, or 32.

--reuse-db hides this on your machine, because each worker keeps its database for the next run. CI starts from an empty database service every time, so there every run pays the full price.

Why the work repeats

Each pytest-xdist worker is its own pytest process with its own session-scoped django_db_setup fixture. pytest-django appends the worker id to the test database name so workers don't share a database (transactional tests would wipe each other's data), and every worker then creates and migrates its own.

On a machine with a free core per worker the builds overlap, so you wait roughly as long as one build takes. The rest is still real work: the builds compete with each other for CPU and for the database server, which runs all that DDL several times over, and on CI machines with more workers than cores it turns into wall time. CI runners can't share anything: each one pays for a full build before its first test, in its own runner minutes.

Django's test runner already migrates once

manage.py test --parallel doesn't have this problem. It creates and migrates test_myapp once, then clones it for each process (test_myapp_1, test_myapp_2, ...). How it clones depends on the backend:

Backend How Django clones the test database
PostgreSQL CREATE DATABASE ... TEMPLATE test_myapp
MySQL mysqldump of the test database, loaded into the new one
SQLite copies the database file (an in-memory database is copied into each process)

python manage.py test --parallel --timing prints how long the setup and each clone took. With pytest-xdist you have to set this up yourself.

PostgreSQL template databases

PostgreSQL can create a database as a copy of another one:

CREATE DATABASE test_myapp_gw0 TEMPLATE test_myapp;

It copies the template's files instead of replaying anything, which for a test database is far cheaper than migrating. One rule: no other session may be connected to the template while it's being copied. From a shell, createdb --template=test_myapp test_myapp_gw0 does the same.

pytest-xdist: migrate in one worker, copy for the others

pytest-django lets you replace its django_db_setup fixture. This one makes the first worker build test_myapp and every worker clone its own database from it, using the same Django functions --parallel uses. A file lock (the pattern pytest-xdist documents for running a session fixture once) makes sure only one worker migrates and nobody is connected to the template while a clone is made:

# conftest.py
import pytest
from django.db import connection
from filelock import FileLock


@pytest.fixture(scope="session")
def django_db_setup(django_db_blocker, django_db_keepdb, tmp_path_factory, worker_id):
    if worker_id == "master":
        # Not running under pytest-xdist: one database, built as usual
        with django_db_blocker.unblock():
            old_name = connection.creation.create_test_db(verbosity=0, autoclobber=True, keepdb=django_db_keepdb)
        yield
        if not django_db_keepdb:
            with django_db_blocker.unblock():
                connection.creation.destroy_test_db(old_name, verbosity=0)
        return

    # A directory every worker of this session shares
    shared_tmp = tmp_path_factory.getbasetemp().parent
    with django_db_blocker.unblock():
        with FileLock(shared_tmp / "django_db.lock"):
            # The first worker creates and migrates test_<name>. The others find it
            # already migrated, so migrate has nothing to do.
            old_name = connection.creation.create_test_db(verbosity=0, autoclobber=True, keepdb=True)
            # PostgreSQL: CREATE DATABASE test_<name>_gw0 TEMPLATE test_<name>
            connection.creation.clone_test_db(suffix=worker_id, verbosity=0, autoclobber=True)
        connection.settings_dict.update(connection.creation.get_test_db_clone_settings(worker_id))
        connection.close()
    yield
    with django_db_blocker.unblock():
        connection.creation.destroy_test_db(old_name, verbosity=0)

It needs filelock (uv add --dev filelock) and pytest-xdist installed, since it uses the worker_id fixture. Each worker's clone is dropped at the end; the template test_myapp stays, like --reuse-db, so the next run only applies new migrations to it. Drop it (dropdb test_myapp) after editing a migration that was already applied.

Limits: it handles the default database only (loop over connections for more), it replaces pytest-django's own fixture so --create-db and --nomigrations no longer apply, and it's meant for PostgreSQL (and MySQL). An in-memory SQLite database can't be shared between processes, so there each worker still migrates.

CI: migrate once, restore everywhere

Across runners, a file lock can't help. Build the database once in a setup job, dump it with pg_dump, and restore the dump on every runner, which is much cheaper than migrating. Cache the dump keyed on a hash of your migrations so it's only rebuilt when they change.

A script that builds the test database exactly the way a test run would:

# scripts/build_test_db.py
import django
from django.db import connection

django.setup()
connection.creation.create_test_db(verbosity=1, autoclobber=True)

A GitHub Actions workflow using it, with four shards via pytest-split and four xdist workers per shard:

jobs:
  test-db:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
    env:
      PGHOST: localhost
      PGUSER: postgres
      PGPASSWORD: postgres
      DJANGO_SETTINGS_MODULE: myapp.settings.test
    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache@v4
        id: cache
        with:
          path: test-db.dump
          key: test-db-${{ hashFiles('**/migrations/*.py', 'uv.lock') }}
      - if: steps.cache.outputs.cache-hit != 'true'
        uses: astral-sh/setup-uv@v6
      - if: steps.cache.outputs.cache-hit != 'true'
        run: |
          uv run python scripts/build_test_db.py
          pg_dump --format=custom --no-owner --no-acl --file=test-db.dump test_myapp
      - uses: actions/upload-artifact@v4
        with:
          name: test-db
          path: test-db.dump

  test:
    needs: test-db
    runs-on: ubuntu-latest
    strategy:
      matrix:
        group: [1, 2, 3, 4]
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
    env:
      PGHOST: localhost
      PGUSER: postgres
      PGPASSWORD: postgres
      DJANGO_SETTINGS_MODULE: myapp.settings.test
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v6
      - uses: actions/download-artifact@v4
        with:
          name: test-db
      - run: |
          createdb test_myapp
          pg_restore --no-owner --no-acl --dbname=test_myapp test-db.dump
          for i in 0 1 2 3; do createdb --template=test_myapp test_myapp_gw$i; done
      - run: uv run pytest --reuse-db -n 4 --splits 4 --group ${{ matrix.group }}

How the pieces fit:

  • --reuse-db makes pytest-django use the restored databases. Django still runs migrate, which applies only migrations missing from the dump, so a stale dump is slower but never wrong about the schema.
  • The createdb --template loop makes one database per xdist worker, named the way pytest-django names them. With the conftest fixture above you can skip the loop and --reuse-db: restore test_myapp and the fixture clones it.
  • The cache key includes uv.lock because Django's own apps and third-party packages ship migrations too, outside your repository.
  • pg_dump refuses to dump a server newer than itself. Check that the PostgreSQL client on the runner is at least the version of the postgres image, or run pg_dump inside the service container.
  • Runners that test against a different database version or engine still need their own build.

Keep the tests correct

  • Every copy is built by the same migrations, so a broken migration still fails the run. Under xdist the first worker runs every migration; in CI, any change to a migration changes the cache key, and the setup job runs every migration on an empty database.
  • Build the dump with the same settings the tests use. If your settings switch on a TESTING flag ("pytest" in sys.modules, say), make sure the build script ends up with the same INSTALLED_APPS and databases.
  • Clones and restored dumps contain whatever the build left in the database, including rows from data migrations, exactly like a freshly migrated one.

How flakytest finds it

flakytest.dev sees every worker and every runner of a run, which one process can't. It adds two insights next to the plugin's own:

  • Run migrations once, not once per worker: the django_db_setup fixture ran in two or more pytest-xdist workers, and the time spent repeating a build another worker already did is at least 5 seconds.
  • Run migrations once per CI run, not on every runner: two or more runners of the same CI run each built the test database, and the repeated time (all builds but the slowest) is at least 5 seconds.

Both are high severity from 30 seconds of repeated work. The summary shows the number of builds, the total and the time per build.

Further reading

Related guides

  • Stop re-running migrations on every test run: Django runs every migration before the first test, on every run. Keep the test database with --reuse-db or --keepdb, or skip migrations with --nomigrations.
  • Run tests in parallel with pytest-xdist: Run pytest in parallel on every CPU with pytest-xdist and -n auto. Per-worker databases with pytest-django, what breaks in parallel, pytest-randomly, and the --dist modes.
  • 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.
  • Make the test PostgreSQL/MySQL server skip durability work: Speed up database-heavy tests by running the test PostgreSQL or MySQL server with fsync=off, synchronous_commit=off and a tmpfs data directory, in Docker or GitHub Actions.

Updated October 18, 2026 · Suggest a change