Django Test Suites on GitHub Actions
Django tests on GitHub Actions lose time to migration replay and 2 vCPU runners. Run manage.py test --parallel on a warp- label with a Postgres service.
Last verified:
A Django suite on GitHub Actions loses its time in two places: rebuilding the test database by replaying every migration, and running tests close to serially because the standard hosted Linux runner for private repositories carries 2 vCPUs. Point runs-on at a warp- label with enough cores for manage.py test --parallel, cache the dependency directory, and decide once how the test schema gets built.
Overview
A Django test job runs four phases inside the billed minutes: dependency install, test database creation, test execution, and teardown. The Postgres or MySQL service container adds a fifth phase before the first step, because the runner creates it and waits on its health check (GitHub service container documentation).
The second phase is the one specific to Django. manage.py test creates a database named test_<your database name>, applies migrations to it, and destroys it when the run finishes (Django testing documentation). That step scales with migration count rather than test count, so a project carrying 400 migrations pays for all 400 on every pull request, including the ones that only rename a field.
The third phase is where runner size shows up. GitHub's standard hosted Linux runner for private repositories carries 2 vCPUs and 8 GB of RAM per GitHub's published specs (GitHub-hosted runner specs, checked on 2026-08-13), so --parallel auto starts two test processes and the suite runs close to end to end.
WarpBuild runners register against your organization under warp- labels, run as ephemeral virtual machines allocated for one job, and carry the same tooling as GitHub-hosted images, so manage.py, psycopg, and the services block behave the same way. The label list and sizes are in the cloud runners documentation.
The rest of this page covers a working workflow with a database service and a parallel run, the two ways to get the test schema built, sizing that budgets cores for test processes and the database together, and the bottlenecks that a bigger runner does not fix. For the pytest side of a Python codebase see faster Python test suites on GitHub Actions.
Configuration
The workflow below runs a Django suite on warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB, 150GB SSD) against a Postgres service container, checks that migrations are in sync, and runs the suite across parallel processes.
name: django-tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: warp-ubuntu-latest-x64-8x
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -h 127.0.0.1 -U app -d app"
--health-interval 5s
--health-timeout 5s
--health-retries 20
--health-start-period 10s
env:
DATABASE_URL: postgres://app:[email protected]:5432/app
DJANGO_SETTINGS_MODULE: config.settings.test
steps:
- uses: actions/checkout@v4
- uses: WarpBuilds/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: requirements/test.txt
- name: Install dependencies
run: pip install -r requirements/test.txt tblib
- name: Check that models and migrations agree
run: python manage.py makemigrations --check --dry-run
- name: Run tests in parallel
run: python manage.py test --parallel 6 --shuffle -v 2Three details in that job are worth reading closely.
The test runner owns the test database. The services block gives Django a server to connect to, and manage.py test creates test_app on it and applies migrations there, so no separate migrate step is needed before the suite. The makemigrations --check --dry-run step is the migration guard: it fails the job when a model change landed without its migration, which is the failure that otherwise shows up as a broken deploy rather than a red check.
--parallel sets the process count, and auto reads the core count of the machine (Django test command reference). Django clones the test database once per process, so eight processes mean eight clones. tblib is in the install line because Django needs it to carry tracebacks back from subprocesses; without it a failure in a parallel run arrives without its stack.
Caching runs through WarpBuilds/setup-python, a drop-in replacement for actions/setup-python that routes pip, pipenv, and poetry caching through the WarpBuild cache with no other workflow change (setup actions documentation). For a project that installs into a virtualenv or holds compiled wheels, add an explicit entry keyed on the lockfile:
- name: Cache the virtualenv
uses: WarpBuilds/cache@v1
with:
path: .venv
key: ${{ runner.os }}-venv-${{ hashFiles('requirements/test.txt') }}
restore-keys: |
${{ runner.os }}-venv-The migration state option
Every Django suite makes one choice about how the test schema is built, and it is worth making deliberately.
Replay migrations. The default. Django applies the full migration history to the test database, so data migrations run and the schema matches what a production deploy produces. The cost is a fixed per run charge that grows with every migration merged.
Build the schema from models. Point MIGRATION_MODULES at None for every app in a test settings module and Django treats the apps as unmigrated, creating tables directly from the model definitions (MIGRATION_MODULES reference):
# config/settings/test.py
from .base import * # noqa: F403
class DisableMigrations:
def __contains__(self, item):
return True
def __getitem__(self, item):
return None
MIGRATION_MODULES = DisableMigrations()Suites on pytest-django get the same behavior from the --no-migrations flag (pytest-django database documentation). The tradeoff is real: data migrations never run, so any test that reads a row created by a migration fails. Keep makemigrations --check --dry-run in the job when you take this path, since it is now the only step that notices a missing migration.
--keepdb is the third option and it behaves differently on ephemeral runners. It preserves the test database between runs, and each WarpBuild job gets a fresh virtual machine, so it pays off only when one job invokes the suite more than once or when the database lives outside the job.
Sizing
Two consumers share the machine: the test processes and the database service. Size for both. Every Linux x64 size carries 4 GB of RAM per vCPU on 150GB SSD storage, so cores are usually the binding constraint for a Django suite, and the practical rule is to set --parallel one or two below the core count so Postgres keeps a core while the suite runs. Rates are from the pricing page, billed per minute, checked on 2026-08-13.
| Label | vCPU | RAM | Suggested --parallel | USD per minute |
|---|---|---|---|---|
warp-ubuntu-latest-x64-4x | 4 | 16 GB | 3 | $0.008 |
warp-ubuntu-latest-x64-8x | 8 | 32 GB | 6 to 7 | $0.016 |
warp-ubuntu-latest-x64-16x | 16 | 64 GB | 12 to 14 | $0.032 |
warp-ubuntu-latest-arm64-8x | 8 | 32 GB | 6 to 7 | $0.012 |
Three limits decide where you land on that ladder.
Clone setup cost. Each process gets its own clone of the test database, so the setup phase repeats per process while the execution phase divides across them. Raising --parallel from 8 to 16 therefore adds eight more clones before it saves any test time. Run once with -v 2 and read the "Cloning test database" lines to see what that phase costs on your schema.
Connections. Postgres defaults max_connections to 100 (Postgres configuration reference, checked on 2026-08-13), and the official image ships that default. Each test process holds at least one connection to its clone, and a suite that sets CONN_MAX_AGE or runs threaded live server tests holds more, so check the ceiling before pushing past 16 processes.
Memory per process. Each process imports the app, the settings graph, and its own fixtures. Budget from your own peak, then take the first size that clears it. WarpBuild CI observability reports CPU and memory percentiles from the runner agent next to the job, which is faster than instrumenting the run yourself.
What the sizes cost
Take a suite that runs 16 minutes on GitHub's standard 2 vCPU hosted runner and 6 minutes at --parallel 6 on 8 vCPUs, across 300 runs a month. Substitute your own two measurements, because parallel efficiency depends on how much of the run is database setup. GitHub's per-minute list prices for x64 Linux are $0.006 for the standard 2 vCPU runner on private repositories and $0.022 for the 8-core larger runner, per the GitHub Actions billing reference, checked on 2026-08-13.
| Setup | Wall clock | Rate per minute | Cost per run | Cost per month |
|---|---|---|---|---|
| GitHub hosted, 2 vCPU | 16 min | $0.006 | $0.096 | $28.80 |
| GitHub hosted larger runner, 8 vCPU | 6 min | $0.022 | $0.132 | $39.60 |
warp-ubuntu-latest-x64-8x | 6 min | $0.016 | $0.096 | $28.80 |
warp-ubuntu-latest-arm64-8x | 6 min | $0.012 | $0.072 | $21.60 |
The x64 row lands on the same monthly figure as the 2 vCPU hosted runner while returning 10 minutes per run to whoever is waiting on the pull request. The list-price gap is the reason: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13. The ARM64 row is available to any Django project whose dependencies publish aarch64 wheels, which covers psycopg[binary], Pillow, and most of the ecosystem. For the sizing method applied to Python suites in general, see sizing runners for Python test suites.
Bottlenecks
Migration replay on every run
This is the Django-specific one, and it is invisible in test timings because it happens before the first test. A long history of migrations turns into a fixed floor under every run, and no runner size removes it. The fixes are the migration state options above, plus squashing old migrations so the history that gets replayed is shorter.
Cloning and database contention
Cloning is serial work that grows with --parallel, and a schema with hundreds of tables makes each clone slower. When wall clock stops falling as you add processes while CPU stays low, the clone phase or the shared server is the limit rather than the core count. Timing a run with -v 2 separates the two, and the guide to service containers covers the health check and startup side of the same machine.
Cold installs and source builds
Without a restored cache, every job resolves and downloads the full requirements set. Where a wheel is missing for the platform and Python version, pip falls back to the sdist and compiles in the job, which is the usual reason a Django install step swings from 20 seconds to several minutes. Install psycopg[binary] rather than building psycopg2 from source, key the cache on the requirements or lock file, and let the restore-keys prefix cover partial matches so a one-line dependency bump avoids a full reinstall.
Work that stays serial
Collecting static files, running system checks, and importing the settings graph happen once per process and shrink for nobody when the runner gets bigger. Move collectstatic out of the test job when the suite does not need it, and keep heavy imports out of module scope in test files.
When a failure only reproduces in GitHub Actions, the Action Debugger pauses the workflow and opens an SSH session on the live runner, which beats re-running a 16 minute suite with extra print statements.
Proof
Public repositories are checkable evidence for the pattern on this page. sequinstream/sequin runs its signoff job on the warp-ubuntu-latest-arm64-32x label with a Postgres service container and a Redis service container, both gated on health checks, and the configuration is readable in the signoff-commit.yml workflow file (checked on 2026-08-13). That is the same shape as the Django job above: a database service on the job machine, a health gate, and a test run sized against the label.
For the Python half, nobodywho-ooo/nobodywho runs its Python jobs on warp-ubuntu-latest-x64-4x with dependency caching in python-ci.yml (checked on 2026-08-13). Both labels come from the catalog on the cloud runners page.
Moving a Django suite over is a one-line change to runs-on plus the cache action swap. When the migrations themselves are the workflow you are building, database migrations on GitHub Actions covers the deploy side.
FAQ
How do I run Django tests in parallel on GitHub Actions?
Run python manage.py test --parallel auto on a runner with more than 2 vCPUs. Django starts one test process per core and clones the test database for each process, so the runner size sets the parallelism directly. On warp-ubuntu-latest-x64-8x that is 8 processes, and setting an explicit --parallel 6 or 7 leaves cores for the Postgres service container sharing the machine.
Should I skip migrations when running Django tests in GitHub Actions?
Skip them when the schema is the only thing your tests need. Setting MIGRATION_MODULES to None for every app in a test settings module makes Django build tables straight from the models, which removes the per-run migration replay. Keep migrations on for any suite that depends on data written by a data migration, and keep a makemigrations --check --dry-run step either way.
What does a Django test job cost on WarpBuild runners?
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.