skip to content

A Django test suite now takes 20 minutes; how do you find where the time goes and cut the database-related share of it?

level: seniorimportance: should knowfreq 44%

answer

  1. measure before changing anything
  2. setup time versus test time
  3. hashing and data creation per test
  4. cores idle, migrations replayed

basics

~20 s

Measure first with --timing and --durations, then attack what dominates: --keepdb for setup, --parallel for idle cores, setUpTestData and factories instead of per-test data, a fast password hasher in test settings, and TestCase over TransactionTestCase where possible.

solid answer

~40 s

I start by measuring: `manage.py test --timing` prints total database setup and teardown time, and `--durations 20` (Django 5.0+) lists the slowest tests. If setup dominates, `--keepdb` reuses the migrated database locally, and a CI cache keyed on the migration files does the same there. If the tests themselves dominate, the usual culprits are per-test data creation (move it to `setUpTestData` with factories), `TransactionTestCase` classes that flush and reload fixtures on every test, and PBKDF2 hashing of test users (use `MD5PasswordHasher` in test settings only). Then `--parallel` spreads classes across cores once they are isolated. `TEST["MIGRATE"] = False` can cut setup further but skips data migrations and migration testing, so I keep at least one full-migration CI run. I would not switch to SQLite for speed if production is PostgreSQL.

code

python · 8 lines
python
# settings/test.py
from .base import *  # noqa: F403

PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
STORAGES = {
    **STORAGES,  # noqa: F405
    "default": {"BACKEND": "django.core.files.storage.InMemoryStorage"},
}

go deeper

for a junior

Know the common levers: --keepdb, --parallel, setUpTestData and a faster password hasher for tests.

for a middle

Explain what each lever saves and its cost, and use --timing and --durations to tell setup time from test time.

for a senior

Run the investigation: measure, fix the dominant cause, keep correctness guarantees such as a full-migration CI job, and re-measure after each change.

for a principal

Treat suite speed as a budget: CI capacity, caching policy and the conventions that stop the suite drifting back to 20 minutes.

## Measure first A slow Django suite usually has one or two dominant causes, and guessing wastes effort. Django's runner has two built-in measurements: - **`--timing`** prints how long **total database setup** (creating, migrating and, with `--parallel`, cloning each database) and **teardown** took, plus the total run time. - **`--durations N`** (Django 5.0+, Python 3.12+) lists the N slowest test cases after the run, with `--durations 0` listing all of them. ```bash python manage.py test --timing --durations 25 ``` Split the 20 minutes into **setup** and **test execution**, then look at the slowest tests for patterns. ## If database setup dominates | Lever | Effect | Cost | |---|---|---| | `--keepdb` | skips create, full migrate and destroy on reuse | stale schema after edited migrations or branch switches | | CI cache of the test database | same win on CI | cache must be keyed on migration files | | `TEST["MIGRATE"] = False` | builds tables from models, no migration replay | data migrations and migration correctness go untested | | Squashing old migrations | fewer operations to replay | one-off maintenance work | A sensible policy is `--keepdb` locally, a fresh or properly keyed build on CI, and at least one CI job that runs the real migrations from zero, because a broken migration is exactly the thing `MIGRATE: False` hides. ## If test execution dominates 1. **Per-test data creation.** Objects built in `setUp()` are created for every test method. Moving shared data to **`setUpTestData`** creates it once per class; with factories the data stays readable. 2. **`TransactionTestCase` overuse.** Each test ends by flushing every table, and fixtures are reloaded before the next test. Use `TestCase` unless the test truly needs real commits, for example to exercise `on_commit` callbacks for real or database-level locking. 3. **Password hashing.** The default PBKDF2 hasher runs 1,500,000 iterations in Django 6.1 on every `create_user()` and `login()`. A test settings module with `PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]` removes that cost; this is for tests only. Include any hasher your fixtures' password hashes use. 4. **Media on disk.** Storage-heavy tests can use `InMemoryStorage` to avoid filesystem writes. 5. **Giant fixtures.** Replace them with targeted factories, or at least keep them on `TestCase` classes where they load once per class. ## Use the cores Once tests are isolated, **`--parallel`** runs test case classes across processes, each with its own clone of the test database (`test_shop_1`, `test_shop_2`, ...). Watch for: - One enormous test class, which cannot be split across workers. - Shared files, cache servers or ports, which cause parallel-only flakiness. - Database connection limits on the CI server, since every worker holds connections to its own clone. ## What not to do - **Do not switch the test engine to SQLite** to save time when production runs PostgreSQL; JSON queries, constraints, locking and case sensitivity differ, and the suite stops proving what it should. - **Do not delete slow tests blindly.** `--durations` often shows a handful of tests doing real network calls or sleeping, which should be mocked or fixed instead. - **Do not tune without re-measuring.** Run `--timing --durations` again after each change so you know which lever paid off. ## A typical outcome A common sequence on a 20-minute suite: a fast hasher and `setUpTestData` cut test time substantially, `--keepdb` removes the setup minute locally, and `--parallel` divides what remains by the number of cores. The exact gains depend entirely on what the measurements showed, which is why measuring comes first. ## A checklist to walk through in an interview 1. Run once with `--timing --durations 25` and write down setup time versus test time. 2. Grep for `TransactionTestCase` and ask of each class whether it really needs real commits. 3. Check `setUp()` methods for object creation that could move to `setUpTestData`. 4. Check the test settings for `PASSWORD_HASHERS`, media storage and any real network backends. 5. Try `--parallel` locally with `--shuffle` in serial first, to surface order dependence before it becomes parallel flakiness. 6. Decide the `--keepdb` and migration policy for local runs and for CI separately.

  • What does TEST['MIGRATE'] = False risk in exchange for faster setup?
    Tables are built straight from the current models, so migrations never run in tests. A broken or non-reversible migration goes unnoticed, and rows inserted by data migrations (`RunPython`) are absent, which can make tests pass or fail differently from a real deployment. Keep at least one CI job that migrates from zero.
  • Why can moving object creation from setUp() to setUpTestData() speed up a suite so much?
    `setUp()` runs before every test method, so its inserts are repeated for each test. `setUpTestData()` runs once per class inside `TestCase`'s class-level transaction, and each test is rolled back to that state, so the inserts happen once per class instead of once per method.

saying these in an interview costs you the question

  • Switching the test database to SQLite to save time while production runs PostgreSQL
  • Using MD5PasswordHasher outside the test settings module
  • Setting TEST MIGRATE to False everywhere without a full-migration CI job
  • Turning on --parallel before checking tests for shared files or caches
  • Assuming --keepdb speeds up the tests themselves rather than setup