The Rust Platform Django test suite took 30 seconds to run. I had a hunch it was database-related. Of course I was wrong. I profiled it with cProfile and cut it from 30 to 3 seconds.

Stop guessing, run the profiler

The instinct on a slow test suite is to start making assumptions: too many fixtures, the database is slow, I should parallelize, it's the GIL. Every one of those is a real fix for some issues. The problem is you don't know until you measure.

From High Performance Python:

Sometimes it’s good to be lazy. By profiling first, you can quickly identify the bottlenecks that need to be solved, and then you can solve just enough of these to achieve the performance you need. If you avoid profiling and jump to optimization, you’ll quite likely do more work in the long run. Always be driven by the results of profiling.

cProfile ships with Python. In this case I pointed it at my test suite:

uv run python -m cProfile -o tests.pstats -m pytest -k unit

That gives you a pstats file. You can read it from the command line, but it's not intuitive:

uv run python -c "import pstats; pstats.Stats('tests.pstats').sort_stats('tottime').print_stats(10)"

Adam Johnson recently released profiling-explorer, a browser viewer over the same data. You can invoke it like this:

uvx profiling-explorer tests.pstats

It opens on http://127.0.0.1:8099. I sorted by internal time and the bottleneck was obvious:

profiling-explorer showing pbkdf2_hmac dominating internal time in the Django test run

Reading the numbers without fooling yourself

Two columns matter:

  • tottime (the viewer calls it "internal ms"): time spent inside a function, excluding what it called. This is where the CPU actually went.
  • cumtime ("cumulative ms"): the function plus everything beneath it. Useful for finding the expensive subtree, useless for finding the hot leaf.

Sort by tottime to find what to fix:

FrameCallstottimeShare
_hashlib.pbkdf2_hmac17726,470 ms82.8%
psycopg2 cursor.execute4,6821,522 ms4.8%

pbkdf2_hmac is Django's default password hasher. It's meant to be slow, because it resists brute-forcing real passwords. But it fires on every test that creates a user: fixtures, create_user, client.login. In production it runs once per login. In a test suite it runs hundreds of times for no security benefit at all.

One more thing the table teaches you: on a high-call-count frame, the call count is the signal, not the time. cursor.execute at 4,682 calls is mostly real database wait, and the count is high because every test sets up its own data: inserts, auth lookups, session reads, across 355 tests. A high count is worth a second look (it can hide an N+1), but you have to confirm that before believing it. (cProfile adds a fixed cost per call too, but at a few thousand calls that overhead is marginal; it only distorts the picture when a frame fires millions of times.) PBKDF2 only ran 177 times, so its time really was concentrated there, and it was the real culprit.

The five-line fix

Swap the hasher to fast MD5 in tests only. An autouse fixture in conftest.py (default "function" scope does it for every test):

import pytest


@pytest.fixture(autouse=True)
def fast_password_hashing(settings):
    settings.PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]

Hash strength is irrelevant under test, so there's no loss of coverage. Test suite speed went from ~30s to ~3s.

It turns out this is also a documented Django trick, which is the point: the profiler led me to a known fix I didn't know I needed, instead of me making assumptions.

The new top frame is cursor.execute at ~4.7k calls, which smelled like an N+1. So I measured that too: I wrapped the suite to capture every query and grouped them by shape. No N+1. The list views already batch their lookups into one query, and the high count was just 355 tests each doing honest setup. The only real waste was a save() path firing redundant COUNT queries, cheap, but easy to halve.

Which is the whole point: I guessed N+1 and was wrong again. The profiler keeps you honest. What's the slowest thing you run every day that you've never actually measured? Let me know on LinkedIn or X/Twitter.