Skip to main content
Updated 2026-11-10Python · Django · pytest

Our Django test suite took ten minutes. It was waiting, not working.

4,660 tests, run serially, in 9 minutes 38 seconds. The slowest-tests list pointed at a real but smaller problem. Comparing two numbers from time pointed at the big one, and the same suite then ran in 57 seconds without a single test being parallelised.

Our backend is a Django project with a few standalone services around it, and one pytest suite covers all of it. By the end of August it took nearly ten minutes on a laptop. Nobody runs a ten-minute suite before every commit, so in practice nobody ran it before every commit.

The obvious move is pytest --durations, find the slow tests, fix them. We did that first, and it was the wrong place to start.

Read user against real first

# 31 August 2026: 4,660 tests, one process
$ time pytest

real    9m38s
user    7m27s

real is wall-clock time. user is time the CPU spent running our process. The two numbers answer different questions:

  • A big gap between them means the suite is waiting: on sockets, on timeouts, on sleep. Here that gap was about two minutes.
  • A big user means the suite is computing. Seven and a half minutes of CPU for 4,660 tests that mostly create a few rows and make one request is far too much work. Something was computing that no test was about.

The slowest-tests list could only ever show the first problem. The second was spread evenly over almost every test, a fraction of a second each, so it appeared nowhere in it.

Seven minutes of password hashing

Django hashes passwords with PBKDF2, and it is slow on purpose: the iteration count goes up with almost every release so that brute-forcing a stolen hash stays expensive. That is exactly what you want on a login form and exactly what you do not want in a test's setUp.

DjangoPBKDF2 iterationsOne make_password()MD5 hasher
5.1870,00060 ms0.03 ms
5.21,000,00069 ms0.03 ms
6.01,200,00084 ms0.05 ms
6.11,500,000104 ms0.03 ms

Measured on 10 November 2026 on an Apple laptop, Python 3.12, eight calls each. On the day we fixed the suite, under a full test run, one call took 141 ms.

Our suite had about 700 places that call create_user or set_password, most of them in setUp, so they ran once per test method rather than once per class. At 141 ms each, that was around 410 of the 578 seconds. One test package alone went from 100.8 s to 13.6 s.

Django's own testing docs suggest a faster hasher for this. We had simply never set one:

# test_settings.py
#
# Test-only, and safe to be weak because it is test-only: this module is never
# imported by a running server. A test that is about the hasher itself (an
# upgrade path, a rehash on login) sets PASSWORD_HASHERS with override_settings.
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]

MD5 is a terrible password hasher and a fine test fixture. The settings module is only ever loaded by the test runner, and the comment says what to do for the few tests that really are about hashing.

Durations that are suspiciously round

The two minutes of waiting did show up in --durations, once we knew to read the numbers rather than the names:

$ pytest --durations=20      # names shortened
10.00s call  <a metrics-sync test>
10.00s call  <a placement test>
 5.01s call  <a drain test>
 3.00s call  <a stale-probe test>

A test that takes 4.37 s is slow. A test that takes exactly 10.00 s is waiting for something with a 10-second limit. These were tests of how a service copes when another component does not answer, so they pointed its HTTP client at an address nothing listens on, and then paid the full production timeout every time: 10 s in two places, 5 s in a third, 3 s in a fourth. The literals were right there in the code.

Those tests are worth having. The timeout value is not what any of them is about, so instead of threading a setting through fifteen call sites we capped every httpx client in that service's conftest:

# conftest.py
import os
import pytest

# Nothing in this suite may wait on a real socket. Tests that point a client at
# an address nothing answers on are right to exist; they should not each sit
# out the production timeout.
TEST_HTTP_TIMEOUT = float(os.getenv("TEST_HTTP_TIMEOUT", "0.05"))


@pytest.fixture(autouse=True)
def cap_httpx_timeouts(monkeypatch):
    import httpx

    for cls in (httpx.AsyncClient, httpx.Client):
        original = cls.__init__

        def capped(self, *args, __original=original, **kwargs):
            kwargs["timeout"] = httpx.Timeout(TEST_HTTP_TIMEOUT)
            return __original(self, *args, **kwargs)

        monkeypatch.setattr(cls, "__init__", capped)

This works because every caller already catches the failure broadly, so a ConnectTimeout after 50 ms takes the same path the slow one did. A test that genuinely needs a slow peer still mocks the transport; the cap only sets the ceiling.

Production pauses nobody turned down

The last of the waiting was deliberate, just not for tests. One service paces its work at one item a second so that a burst does not stampede everything downstream. A scaler script slept two seconds between actions. One test simulated a hang of five seconds against a probe that gives up after 0.05.

Each pause was correct in production, and each test paid it in full. The pacing already read its delay from configuration, so the tests now set it to almost nothing. A second loop paced itself with a bare asyncio.sleep(1); it got the same kind of setting, the only production change in the whole fix. The scaler's two-second pause is real behaviour where that script runs, so its tests patch time.sleep in their own conftest instead. The simulated hang became half a second, still ten times the probe's limit.

# config.py: one pause already had a setting, the other one gets it
PLACEMENT_STAGGER_DELAY = float(os.getenv("PLACEMENT_STAGGER_DELAY", "1.0"))  # seconds
DRAIN_STAGGER_DELAY = float(os.getenv("DRAIN_STAGGER_DELAY", "1.0"))  # was a bare sleep(1)

# conftest.py: turned right down, but not to zero, so the branch
# that sleeps is still the branch under test
os.environ.setdefault("PLACEMENT_STAGGER_DELAY", "0.001")
os.environ.setdefault("DRAIN_STAGGER_DELAY", "0.001")

Setting the delays to 0.001 rather than 0 is deliberate: the code path that sleeps is still the one under test, just without the wait.

Same tests, still serial

# 1 September 2026: the same 4,660 tests, one process
$ time pytest

real    0m57s
user    0m40s

578 seconds to 57, with nothing parallelised and no test removed or skipped. Note the shape: real and user are now close together, and user is small.

The next day we tried pytest -n 8, and 61 to 81 tests failed, a different set on every run. That turned out to be a separate bug the serial order had been hiding. A pair of transactional test classes truncated every table on teardown, including rows that a data migration seeds, and nothing put them back. Serially it never mattered, because pytest-django sorts transactional tests to the very end of the run, so there was nobody left to break. Under xdist that order no longer holds. Once the seeded rows were restored after each flush, pytest -n 8 --dist loadfile ran green in 23 seconds.

Two months later

The suite has grown since, and it is slow again:

# 10 November 2026: 8,063 tests, one process
$ time pytest

real    6m35s
user    6m08s

This time the same check gives a different answer. User is almost all of real, so the suite is doing work, and --durations agrees: the slowest tests are new randomised simulations of subscription billing, months of renewals, retries and lost webhooks replayed per test, where the four slowest take about 67 seconds between them. Those tests are meant to be heavy. The fix for that is a different one: run them in parallel or on a schedule, not make them stop waiting. Knowing which of the two problems you have is the whole point of looking at user first.

The checklist

  • Run time pytest and compare user with real before touching anything.
  • A big gap means waiting: look for timeouts, sleeps and sockets.
  • A big user spread across every test means hidden per-test work, and in Django the first suspect is the password hasher.
  • In --durations, a round number such as 10.00, 5.01 or 3.00 is a timeout literal, not a slow test.
  • Give every deliberate production pause a setting, and turn it down to almost zero in tests, not to zero.
  • Before trusting pytest -n, run it a few times: a different set of failures on each run means the serial order was hiding a leak.

More notes like this: engineering notes, and our open-source channels-jwt-subprotocol. We build TheLoops, a service that streams pre-recorded video 24/7 to YouTube, Twitch and Kick.

Drafted with help from an AI assistant; the measurements, the fixes and the mistakes are ours.