Module 5: Fast Ci Caching And Parallelism
4. Parallelizing with `pytest-xdist` and `-n auto`
Description
The cache solved the first sink: not reinstalling the same thing. But the second remains, the one lesson 2 measured on the Reservo suite —six seconds of tests running in a line, one after another, in a single process—. Caching doesn't help there: the tests aren't downloaded, they're executed, and executing them in sequence is slow by definition when there are many or when some take time. The lever for this sink is another: parallelize. Instead of one process chewing through the whole line, several processes split it and run at once.
By the end you'll know how to use pytest-xdist, the plugin that distributes your tests across several worker processes, and you'll see the speedup for real, executed locally on the Reservo suite. You'll understand the difference between -n auto (one worker per CPU core) and -n 4 (a fixed number of workers), you'll read the new header pytest prints in parallel —created: 12/12 workers, 12 workers [23 items]—, and you'll measure with your own eyes how the same suite drops from 6 seconds to a little over one without changing a single test line. You'll also understand why the speedup isn't infinite: starting workers costs something, and that cost sets a floor. This is the module's most "executable" lesson: every number I cite I measured by running the suite on a real machine.
Connection to the module: this lesson attacks sink 2 that lesson 2 diagnosed, complementing the cache from lesson 3 (which attacked sink 1). With both levers on you have the module complete technically. But parallelism brings a condition lessons 5 and 6 develop: to distribute tests you have to know which to distribute first (splitting the suite, lesson 5) and, above all, the tests have to be independent (isolation, lesson 6). Here we start the engine; those two lessons make sure it doesn't break. And lesson 7 will put a price on this speedup: parallelizing consumes CPU, and more isn't always better.
A single cashier versus several registers
Let's take up lesson 1's buffet, now at the register. With a single cashier, a hundred diners form a line and pay one at a time: if each payment takes six seconds, the whole line takes ten minutes, and diner number one hundred waits that whole time even though their payment is identical to the first's. The bottleneck isn't the cashier's speed —they type at a good pace—, it's that there's a single line and a single executor.
Open four registers and split the line among them. Now four payments happen at once; the line of a hundred advances almost four times faster. You didn't make the cashier faster or change the customers: you changed how many things happen at the same time. That "at the same time" is parallelism, and its gain is proportional to how many registers you open —up to a limit, because opening a register costs (you have to bring a register, seat a cashier), and with more registers than customers, the extras sit idle—.
pytest-xdist opens those registers for your tests. Each "register" is a worker process: a copy of Python running in parallel, with its own part of the suite. With twelve workers on a twelve-core machine, twelve tests run at once instead of one. The line of tests that took six seconds serially gets split, and the wall-clock time —what you wait watching the terminal— plummets. The condition, the same as in the buffet: each customer has to be able to pay at any register without depending on the next one. A test that needs what another test left can't run at a different register —that's lesson 6—.
pytest-xdistdistributes your tests across several worker processes that run at once.-n autoopens one worker per CPU core. The speedup is real and big when the suite is slow and the tests are independent; it has a floor, because starting workers costs.
Installing and turning it on
pytest-xdist is a pytest plugin: it's installed with pip and pytest detects it on its own. It's added to requirements.txt like any other test dependency:
# requirements.txt
pytest==9.1.1
pytest-xdist
And it's installed as always:
pip install pytest-xdist
Once installed, you have a new flag: -n, followed by the number of workers. The form you'll almost always want is -n auto:
python -m pytest -n auto
auto means "open as many workers as this machine has cores". On this guide's machine, which has 12 CPUs, -n auto opens 12 workers. On an 8-core one it would open 8. The beauty of auto is that you don't nail down a number: the same command squeezes all the available hardware, whatever it is, on your laptop and on the CI runner. You can also ask for a fixed number with -n 4 (four workers, regardless of how many cores there are), and later we'll see when each is best.
That pytest detected the plugin is confirmed by the header, in the plugins: line:
plugins: xdist-3.8.0
There's xdist-3.8.0 listed among the active plugins. Without installing it, -n wouldn't exist and pytest would tell you unrecognized arguments: -n. With it, -n auto distributes the suite.
The real demo: serial versus -n auto
Here's the heart of the lesson, measured for real on Python 3.14.0 with pytest 9.1.1. We're going to run the slow part of the Reservo suite —the twelve monthly-report tests, each with half a second of wait— three ways, and compare the times.
First, serially (without -n), as you ran the whole guide:
python -m pytest -m slow
What to expect (real output):
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/reservo-m5
configfile: pyproject.toml
testpaths: tests
plugins: xdist-3.8.0
collected 23 items / 11 deselected / 12 selected
tests/test_reports.py ............ [100%]
====================== 12 passed, 11 deselected in 6.11s =======================
Twelve slow tests, 6.11s. They're the twelve sleep(0.5) running one after another: half a second, twelve times, in a line. (The -m slow selects only the tests marked as slow; we break it down in lesson 5. For now just notice the time.)
Now the same twelve tests, adding -n auto:
python -m pytest -m slow -n auto
What to expect (real output):
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/reservo-m5
configfile: pyproject.toml
testpaths: tests
plugins: xdist-3.8.0
created: 12/12 workers
12 workers [12 items]
............ [100%]
============================== 12 passed in 1.17s ==============================
1.17s. The same twelve tests, all green, in less than a fifth of the time. From 6.11 s to 1.17 s. We touched no test: we split them across twelve workers, and since each worker ended up with one or two half-second tests, they all finished almost at once. Six seconds of line became a little over one.
And now, the complete suite —the 23 tests, fast and slow— to see the whole picture, first serially and then with -n auto:
============================== 23 passed in 6.10s ==============================
created: 12/12 workers
12 workers [23 items]
............................ [100%]
============================== 23 passed in 1.15s ==============================
The whole suite drops from 6.10 s to 1.15 s. That's the speedup the module is after, and you just saw it executed, not described.
How to read the header in parallel
The run with -n changes the header, and it's worth reading the new lines slowly because you'll see them the whole rest of the module.
created: 12/12 workers — pytest-xdist started the workers before running anything. 12/12 means "I asked for 12 and all 12 are ready". These are twelve independent Python processes, each a copy of the interpreter ready to receive tests. Starting them takes a moment (that's the cost of "bringing the registers"), which is why it appears before the first line of results.
12 workers [23 items] — replaces the collected 23 items line of the serial run. It reads: "twelve workers are going to split 23 tests". It's the confirmation that the work was divided: instead of one process with the 23, twelve processes with a handful each.
The dot line no longer comes ordered by file. Serially you saw tests/test_reports.py ............ with the dots grouped by file, in order. In parallel you see a row of "loose" dots —............................— because the results arrive from twelve workers at once, in the order each finishes its test, not in the file's order. A dot is still a test that passed; what changes is that you can no longer read the source file from the dot's position. (If you need to see which test ran in which worker, there are flags for that, but the final summary —23 passed— is identical: same tests, same verdict.)
The rest of the header didn't change: same platform, same Python, same plugins. The only new thing is the evidence that the work was distributed. And the final summary is the proof that distributing didn't alter the result: 23 passed, exactly the same 23 tests as serially, only faster.
Why the speedup isn't infinite
You might expect twelve workers to make the suite twelve times faster. It dropped from 6.10 to 1.15 —about five times—, not twelve. Why not the theoretical maximum? For three reasons worth understanding, because they explain lesson 7's trade-off.
Starting workers costs. Each worker is a Python process you have to start, load, and coordinate. With twelve workers, that startup —the created: 12/12 workers— takes a fraction of a second that you don't pay serially. For a six-second suite, that fraction is small and worth it; for a suite that already takes half a second, the startup could cost more than you save, and -n auto would make it slower. Parallelism pays off when there's enough work to distribute.
The distribution isn't perfect. Twelve tests among twelve workers sounds like "one each, all finish together", but pytest-xdist distributes the tests to the workers as they free up, and some workers end up with two tests while others with one. The worker that took two takes twice as long, and the whole run doesn't finish until the last worker finishes. That imbalance prevents perfect speedup.
There's a physical ceiling. With twelve cores, twelve tests can really run at once; the thirteenth would have to wait for a core to free up. Opening more workers than cores doesn't help (the extra registers sit idle or fight over the same core). That's why -n auto picks exactly one worker per core: it's the point where you squeeze the hardware without going over.
The practical consequence: the speedup is big but has a floor, and that floor is set by the startup plus the slowest test that can't be split. Lesson 7 measures the full curve —how the return flattens as you add workers— and teaches you to choose how many without waste.
In the CI workflow
Taking this to the pipeline is one line. Where module 2's workflow ran pytest, now it runs pytest -n auto:
- name: Run the test suite
run: pytest -n auto
With pytest-xdist in requirements.txt (so the runner installs it) and -n auto in the command, CI distributes the suite across the runner's cores. GitHub runners by default have several cores, so -n auto leverages them without you knowing in advance how many there are —that's, again, the advantage of auto over a fixed number—. Remember the module's honesty: this step is content (no runner runs here), but the speedup it produces you just measured locally, and the runner would do the same with its own core count.
Common mistakes
Using -n without installing pytest-xdist. What happens: someone adds -n auto to the command but forgets to put pytest-xdist in requirements.txt, and pytest fails with error: unrecognized arguments: -n. Why it happens: -n isn't a pytest flag, the plugin provides it; without the plugin, it doesn't exist. How to spot it: the message unrecognized arguments: -n is unmistakable. How to fix it: add pytest-xdist to the dependency list and install it; confirm it by seeing xdist-x.y.z in the header's plugins: line.
Parallelizing a suite that's already fast and making it slower. What happens: someone puts -n auto on a suite of 20 instant tests, and the run takes longer than serially, because starting twelve workers costs more than distributing 20 tests that already flew. Why it happens: "parallel = faster" is true only when there's enough work to distribute. How to spot it: compare the time with and without -n; if -n auto is slower, your suite doesn't have enough work to justify the workers. How to fix it: use -n auto where it hurts (slow or big suites) and leave it out where the suite is already instant. Lesson 7 gives the complete rule.
Reading the "unordered" dots as an error. What happens: someone sees the row of loose dots from -n auto —not grouped by file like serially— and thinks something corrupted or the tests ran "wrong". Why it happens: the parallel output arrives from several workers at once, in the order they finish, not in the file's order, and that's disconcerting the first time. How to spot it: look at the final summary: if it says 23 passed, all 23 ran and passed, regardless of the dots' order. How to fix it: understand that the dots' order in parallel reflects when each worker finished, not a problem; the verdict that matters is the final count, identical to the serial run's.
Exercises
Exercise 1 — Calculate the speedup. Reservo's slow suite takes 6.11 s serially and 1.17 s with -n auto (12 workers). (a) How many times faster is the parallel version, rounding? (b) If the speedup were perfect (12 times), how long would it take? (c) Why is the real number (1.17 s) higher than that ideal?
See solution
- (a) 6.11 ÷ 1.17 ≈ 5.2 times faster. A big speedup, though not the theoretical maximum.
- (b) With a perfect 12× speedup, it would take 6.11 ÷ 12 ≈ 0.51 s —exactly the time of a single slow test, which is the minimum possible: twelve 0.5 s tests distributed among twelve workers would give half a second if each worker ran exactly one and starting were free—.
- (c) The real one (1.17 s) is higher than the ideal (0.51 s) for the lesson's three reasons: starting the twelve workers costs a fraction of a second you don't pay serially; the distribution isn't perfect, so some worker took two tests (1 s of work) while others one; and the run doesn't finish until the last worker finishes. The difference between 0.51 and 1.17 is, precisely, the price of parallelism.
The lesson: the speedup is real and big (5×), but the theoretical ideal (12×) isn't reached because of the cost of starting and coordinating workers. That's normal and expected.
Exercise 2 — Diagnose the -n that doesn't work. A teammate runs python -m pytest -n auto and gets this error. What happened and how is it fixed?
ERROR: usage: pytest [options] [file_or_dir] ...
pytest: error: unrecognized arguments: -n auto
See solution
What happened: pytest-xdist isn't installed. The -n flag doesn't belong to pytest; the xdist plugin provides it. Without the plugin, pytest doesn't recognize -n and aborts with unrecognized arguments: -n auto.
The fix is to install the plugin:
pip install pytest-xdist
And, so CI also has it, add it to requirements.txt:
pytest==9.1.1
pytest-xdist
After that, python -m pytest -n auto works, and you confirm it by looking at the header: the plugins: line now includes xdist-x.y.z, and the lines created: N/N workers and N workers [M items] appear. The clue for the future: if a flag gives unrecognized arguments, it's almost always missing the plugin that provides it, not that you wrote it wrong.
Exercise 3 — auto or a fixed number. Explain what -n auto does versus -n 4, and give a case where you'd prefer auto and one where you'd prefer a fixed number like 4.
See solution
-n auto opens one worker per core of the machine where it runs: 12 on a 12-core one, 8 on an 8-core one. -n 4 opens exactly four workers, regardless of how many cores there are.
- You'd prefer
autowhen you want to squeeze all available hardware without nailing down a number, and the same command runs on different machines —your laptop, the CI runner—. Sinceautoadapts, a singlepytest -n autoleverages 12 cores on one machine and 8 on another without you editing anything. It's the default option for most cases. - You'd prefer a fixed number like
-n 4when you need to leave cores free for something else —for example, on a shared runner where you don't want to hog all the CPU, or on your laptop while working on something else and you don't want it to freeze—, or when you measured that more than four workers add no speedup for your suite but do add cost (lesson 7). Fixing the number gives you control over how much machine you consume.
The practical rule: auto by default (squeezes what's there); a fixed number when you want to deliberately limit resource consumption.
Summary and next step
In this lesson you turned on the module's second lever, and executed it for real: parallelizing with pytest-xdist. You saw it with the buffet's registers: not making the cashier faster, but opening more registers and splitting the line. You installed the plugin, used -n auto (one worker per core), and measured the real speedup on the Reservo suite: the twelve slow tests dropped from 6.11 s serially to 1.17 s in parallel (about 5 times), and the complete suite from 6.10 s to 1.15 s, without changing a single test line —the summary still said 23 passed, same tests, same verdict—.
You learned to read the header in parallel: created: 12/12 workers (the processes ready), 12 workers [23 items] (the distribution), and the row of loose dots that arrives in the order the workers finish, not in the file's. And you understood why the speedup isn't infinite: starting workers costs, the distribution isn't perfect, and there's a physical ceiling in the number of cores —three reasons that set a floor and that lesson 7 will turn into a cost curve—.
Before moving on you should be able to: install pytest-xdist and run pytest -n auto; read the header in parallel (N workers [M items]); explain the difference between -n auto and -n 4 and when to use each; and justify why the real speedup (5×) falls below the theoretical ideal (12×).
What's next, in lesson 5, is fine-tuning what gets parallelized and in what order. Not every test is equally expensive: most of Reservo flies, and only the twelve reports weigh. You're going to learn to split the suite —mark the slow ones with @pytest.mark.slow, run the fast ones first to have feedback in hundredths of a second (11 passed in 0.01s) and the slow ones separately, and find the culprits with --durations—. With the suite well split, parallelism lands where it's really needed.
Resources
- pytest-xdist on PyPI — the plugin's official page: installation,
-n auto, and the distribution options. The reference for the lever we executed here. pytest-xdiston GitHub — the repository with the complete documentation: how it distributes the tests, the distribution modes (--dist), and the warnings about shared state that lesson 6 develops. Consult it when you want fine control.- How to run tests in parallel — pytest-xdist documentation — the official guide to the distribution modes and how to choose the number of workers. Useful for going beyond
-n autowhen you need it. - How to invoke pytest (pytest documentation) — the reference for the pytest flags you combine with
-n, including the marker selection (-m) we used here and break down in lesson 5.