Module 5: Fast Ci Caching And Parallelism
1. Module introduction: fast and in parallel
Description
Up through module 4, your pipeline grew in coverage but also in weight. It started running the Reservo suite in a single environment; then you added a matrix of Python versions and, perhaps, operating systems, and suddenly each push triggers not one run but three, six, or nine. Each of those cells does exactly the same as the others: it downloads and installs pytest from scratch, and then runs the tests one after another, in single file. The result is a more complete pipeline and, at the same time, a slower one. And CI slowness isn't a cosmetic detail: it's the factor that decides whether the pipeline is used or ignored.
By the end of this lesson you'll understand why CI speed matters so much —not just for comfort, but because a slow CI gets skipped— and what the two levers are that this module gives you to speed it up. The first is caching dependencies: stop reinstalling the same thing on every run and reuse what's already installed as long as requirements.txt doesn't change. The second is parallelizing the suite with a tool called pytest-xdist: instead of running the tests in a line, distribute them across all your processor's cores and run them at once. You're going to see this second lever work for real, locally, on the Reservo suite, with the speedup measured: the same slow suite that takes 6.10 seconds serially drops to 1.15 seconds in parallel. It's not an invented number; I ran it to write this.
Connection to the module: this lesson is the map. Here you install the two big ideas —cache and parallelism— and watch them beat once. Lesson 2 opens up the why: what happens to a team when CI takes too long, and where exactly the time goes. Lesson 3 is the first complete lever: actions/cache and the requirements.txt hash key. Lesson 4 is the second lever executed for real: pytest-xdist with -n auto, with the real speedup demo and pytest's new header. Lesson 5 splits the suite into fast and slow so the most urgent feedback arrives first. Lesson 6 is the hinge lesson: isolation, the requirement that makes parallelism possible, demonstrated with a test that breaks under -n. Lesson 7 is the economic decision: how much to parallelize without burning money. And lesson 8, the mini-project, pulls it all together: you speed up Reservo's CI with cache and -n auto, and you fix the test that broke in parallel.
A note about the boundary, because this module leans on the previous ones and doesn't invade the ones that follow. The matrix —running on several versions— was module 4; here we don't rewrite it, though we do name it, because it's exactly the one that benefits most from speeding up. Coverage gates —a threshold that breaks the build— are module 6, not this. And flaky tests —the ones that fail intermittently— are module 7. That last boundary is subtle and worth fixing from now: in lesson 6 you're going to see a test that fails under parallelism, and you might think "that's a flaky". It isn't. It's a poorly isolated test, which fails deterministically as soon as you distribute it across processes; isolation is a requirement of this module. The real flaky —the one that fails sometimes yes and sometimes no without you changing anything— belongs to module 7. Here the focus is a single one: speed. Cache to not reinstall, parallelism to not run in a line.
The buffet that restocks all at once and the registers that multiply
Imagine a very busy buffet at lunchtime. There are two distinct bottlenecks, and confusing them leads to fixing the wrong one.
The first: every time a tray empties, the cook goes down to the storeroom, cuts the vegetables from scratch, washes them, and cooks them, even though they're exactly the same ones they cut half an hour ago. It's repeated work that didn't change from one time to the next. The obvious solution isn't to cook faster: it's to prepare the vegetables once and keep them ready, and only cut again when the menu really changes. That's caching: reusing a result you already computed because the inputs didn't change. In your CI, the "vegetables cut over and over" are the dependencies installed identically on every run.
The second bottleneck: there's a single cash register, and the diners form a very long line. The food is ready, but payment goes one at a time. The solution isn't for the cashier to type faster either: it's to open more registers and split the line among them. With four registers, the line advances almost four times faster —as long as each customer can pay at any register, without depending on what the customer at the next one did—. That's parallelizing: splitting a job among several executors that run at once. In your CI, "the customers in line" are the tests, and "opening more registers" is pytest-xdist distributing them across your processor's cores.
Notice the detail at the end, because it's the whole of lesson 6: the parallel registers only work if each customer is independent. If the customer at register 2 needs the change the customer at register 1 left, opening more registers breaks the system instead of speeding it up. A test that depends on what another test left behind is exactly that customer: it passes when there's a single register (everything in order, in line), and breaks as soon as you distribute.
A slow CI has two sources: repeated work (reinstalling the same thing) and queued work (running the tests one after another). The cache attacks the first; parallelism, the second. And parallelism only works if the tests are independent.
Reservo, as we left it — and a suite that now weighs
We continue with Reservo, the coworking meeting-room booking system we've been testing since the first module. Pure Python logic: no database, no network, no hidden clocks. Its pieces, in case you need a refresher:
Room(id,name,capacity,hourly_cents),Member(id,name,tier:"basic"or"pro"),Booking(with itsprice_centsfield, thestart, theendas a half-open range[start, end), and itsstatus).- The core functions:
price_cents(room, member, hours),refund_cents(booking, price_paid_cents, now),overlaps,is_available,book, and theCalendarthat stores the bookings in memory. - The anchor numbers, the whole guide's checksum: basic 3 h → 7500, pro 3 h → 6000 (20% discount), and the refund on 6000 paid: 6000 if you cancel 72 h ahead (≥ 48 h, 100%), 3000 at 36 h (24–48 h, 50%), 0 at 12 h (< 24 h). All in int cents.
That suite —11 tests split across test_pricing.py, test_refunds.py, and test_availability.py— is lightning fast: it runs in hundredths of a second. And there's the problem for this module: a suite that already flies is no good for demonstrating how to speed up. We need a suite that really weighs, because speed is only noticeable when there was slowness to remove.
So we add something realistic to Reservo: a monthly revenue report. A growing product ends up having tests more expensive than the unit ones —tests that render a PDF, that query a data source, that spin up a subprocess—. Reservo debuts test_reports.py with twelve of those slow tests, one per month, each summing the revenue of a handful of bookings. So the demo is reproducible on any machine, the slowness is simulated with a wait:
# tests/test_reports.py (fragment)
import time
import pytest
from reservo.models import Room, Member
from reservo.pricing import price_cents
ROOM = Room(id="r1", name="Focus", capacity=4, hourly_cents=2500)
BASIC = Member(id="m1", name="Ana", tier="basic")
PRO = Member(id="m2", name="Ben", tier="pro")
def _slow_report_total(bookings):
"""Sum the price of each booking, in cents. Slow on purpose."""
time.sleep(0.5) # simulates a slow data source or a render
return sum(b for b in bookings)
@pytest.mark.slow
def test_report_january_basic_hours():
total = _slow_report_total([price_cents(ROOM, BASIC, 3) for _ in range(4)])
assert total == 30000 # 4 basic 3h bookings: 4 x 7500
Stop at the time.sleep(0.5). It's not a cheat; it's an honest substitute. A genuinely slow test takes half a second because it waits for the network, the disk, or a subprocess; here that half second is produced by a sleep so you get exactly the same times as I did, without depending on your network or your disk. The arithmetic being tested is real and in cents: four basic 3 h bookings sum to 4 × 7500 = 30000. The twelve tests carry @pytest.mark.slow, a marker —a label you put on a test— that in lesson 5 will serve to separate the slow from the fast. For now keep the picture: the Reservo suite went from 11 instant tests to 23 tests, twelve of them with half a second of wait each. That's, summed, six seconds of line. Exactly what we need to see parallelism work its magic.
First contact: the same suite, serial and parallel
Before breaking anything down, let's see the two pictures that sum up the module. Both are real, executed on the machine where I write this: Python 3.14.0, pytest 9.1.1, with pytest-xdist installed.
First, the complete suite serially, as you ran it the whole guide: the 23 tests, one after another.
python -m pytest
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
tests/test_availability.py .... [ 17%]
tests/test_pricing.py .... [ 34%]
tests/test_refunds.py ... [ 47%]
tests/test_reports.py ............ [100%]
============================== 23 passed in 6.10s ==============================
Read the end: 23 passed in 6.10s. All green, but a long six seconds. Those six seconds are, almost entirely, the twelve sleep(0.5) running in a line: half a second, one after another, twelve times. The other 11 tests are barely noticeable. Now the same suite, without changing a single test line, adding only the -n auto flag:
python -m pytest -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 [23 items]
........................ [100%]
============================== 23 passed in 1.15s ==============================
23 passed in 1.15s. The same suite, the same 23 tests, all green, in a fifth of the time. From 6.10 s to 1.15 s. We changed no test; we just let them run at once instead of in a line.
And notice the two new header lines, because you're going to read them the whole module:
created: 12/12 workers— pytest-xdist started twelve worker processes, one per core of this machine (it has 12 CPUs).-n automeans "use as many workers as cores you have".12 workers [23 items]— replaces thecollected 23 itemsline of the serial run. It says: twelve workers are going to split 23 tests. Instead of one process chewing through the whole line, twelve split it.
Those are the two announced levers in miniature. In the serial run there was no cache or workers; in the -n auto one, parallelism distributed the work. The cache isn't visible in these pictures because it's a CI optimization (nothing is reinstalled locally between runs), but its effect is the same kind of saving: not repeating work you already did.
The module's honesty: what runs for real and what's content
As in the whole guide, it's worth being clear about what gets executed and what gets explained, because in this module the line runs right through the middle.
Parallelism with pytest-xdist is executed for real, locally. Every time I cite —6.10 s, 1.15 s, 1.83 s— I measured by running the suite on a real machine with Python 3.14.0. The speedup you see is genuine, and you can reproduce it: you install pytest-xdist with pip, run python -m pytest -n auto, and get your own number (which will depend on how many cores your machine has). The N workers [M items] header comes from an authentic run.
Dependency caching with actions/cache is content. actions/cache is a GitHub action that only makes sense inside a CI runner, saving and restoring files between cloud runs. Here we don't have a runner, so the cache YAML you're going to write and read as content —I show you how the step looks and how its log would read, with cache hit and cache miss—, and we anchor it to something you do see locally: the Using cached that pip prints when it reuses a package it already downloaded. It's the same principle (don't re-download what you already have), at two scales.
Put another way: when you read "1.15 s with -n auto", I measured that number by executing; when you read a CI log with Cache restored from key: ..., that's the honest format of how it would look on a runner, not a screenshot of a phantom CI.
Common mistakes
Believing "CI is green" is enough, without looking at how long it takes. What happens: a team sets up a correct but slow pipeline —twelve minutes per push— and calls it good because "it passes". Within a few weeks no one waits for the green: they merge as soon as the local tests pass and CI stays as an ornament that sometimes warns late about a problem already merged. Why it happens: correctness is visible; slowness is suffered in silence and normalized. How to spot it: ask your team "do you wait for CI's green before merging?". If the honest answer is "almost never, it takes too long", your CI is too slow. How to fix it: this module's two levers. A CI that gives a verdict in a minute is waited for; one that takes fifteen is ignored.
Trying to speed up by cooking faster instead of attacking the source. What happens: someone sees a slow CI and buys a more powerful runner, or rewrites the tests so each is a tiny bit faster, and it barely improves. Why it happens: they didn't distinguish the two sources of slowness —repeated work and queued work— and applied the wrong tool. How to spot it: look at where the time goes. If half is "installing dependencies", the problem is caching, not CPU. If half is "running tests" in a line, the problem is parallelism. How to fix it: measure first (lesson 5 teaches you --durations), and apply the lever that corresponds to each source.
Confusing "fails in parallel" with "is flaky". What happens: someone turns on -n auto, a test that always passed turns red, and concludes "parallel tests are unstable, better not use xdist". Why it happens: the test shares state with another and parallelism revealed it; but it's easy to blame the tool. How to spot it: if the test fails every time you run in parallel (not sometimes), it's not flaky, it's a deterministic isolation problem. How to fix it: isolate the test (lesson 6). A real flaky —intermittent failure with no clear cause— is another animal, from module 7. Don't turn off parallelism over a poorly isolated test; fix it.
Exercises
Exercise 1 — Identify the lever. For each slow-CI symptom, say which of this module's two levers attacks it —cache or parallelism— and why in one sentence. (a) "The Install dependencies step takes 90 seconds on every run, even though I haven't touched requirements.txt in weeks." (b) "I have 400 tests that run in a line and take 8 minutes, even though each is independent." (c) "My 3-version matrix installs the same dependencies three times per push."
See solution
- (a) Cache. The work repeats identically (installing the same) without the inputs changing. Caching the dependencies avoids reinstalling as long as
requirements.txtdoesn't change: that's exactly whatactions/cachedoes with the hash key (lesson 3). - (b) Parallelism. The problem is "queued work": 400 independent tests running one after another. Distributing them across cores with
pytest-xdist -n autoruns them at once (lesson 4). The clue that it's possible is "each is independent" —lesson 6's requirement—. - (c) Cache (mainly). Installing the same thing three times is repeated work; a cache per matrix cell avoids downloading and compiling again in each version. (Parallelism within each cell would also help, but the complaint here is the repeated reinstallation.)
The mechanical rule: if the waste is "I do the same thing again without anything changing", it's cache. If the waste is "I could do several things at once but I do them in a line", it's parallelism.
Exercise 2 — Read the xdist header. A teammate runs their suite with -n auto and sees this header. Answer: how many worker processes did it start?, how many tests are there?, and what does this tell you about how many cores their machine has?
created: 8/8 workers
8 workers [150 items]
See solution
- Workers started: 8. The
created: 8/8 workersline says pytest-xdist created the 8 workers it asked for, all ready. - Tests: 150.
8 workers [150 items]means "eight workers are going to split 150 tests". It's the parallel version ofcollected 150 items. - Machine cores: since it used
-n autoand auto picks one worker per core, their machine has (very probably) 8 CPUs. On this guide's machine,-n autogave 12 workers because it has 12 cores; on theirs, 8.
The moral: -n auto adapts the number of workers to the hardware, so the same command squeezes 8 cores on their machine and 12 on the guide's, without you writing a fixed number.
Exercise 3 — Which module solves this? For each situation, say whether this module (speed: cache and parallelism) solves it or whether it belongs to another module of the guide, and name it in one sentence. (a) "I want my slow suite to run in 1 second instead of 6." (b) "A test passes sometimes and fails sometimes, without me changing anything." (c) "I want the build to break if coverage drops below 80%." (d) "I turned on -n auto and a test that always passed now fails always in parallel."
See solution
- (a) Run the slow suite faster → this module, specifically lesson 4 (parallelism with
pytest-xdist). It's the definition of the speedup we're after. - (b) A test that passes sometimes and fails sometimes → module 7 (flaky tests in CI). An intermittent failure with no code change is the definition of flaky, and its treatment —retry, quarantine— belongs to module 7.
- (c) Break the build if coverage drops → module 6 (quality gates and coverage thresholds). A threshold that breaks the build (
--cov-fail-under) is a quality gate, not a speed optimization. - (d) A test that fails always in parallel → this module, lesson 6 (isolation). Watch out for the trap: it fails always in parallel, not sometimes, so it's not flaky; it's a poorly isolated test that shares state, and isolation is the requirement this module teaches you to meet.
The fine distinction between (b) and (d): "sometimes yes, sometimes no" is flaky (module 7); "every time I parallelize" is lack of isolation (this module, lesson 6).
Summary and next step
In this lesson you installed the two ideas that hold up the module. A slow CI isn't just uncomfortable: it gets ignored, and an ignored pipeline protects nothing. Slowness has two distinct sources —repeated work (reinstalling the same thing) and queued work (running the tests one after another)— and each corresponds to a lever: caching dependencies to not reinstall, and parallelizing with pytest-xdist to not run in a line. You saw it with the buffet analogy: prepare the vegetables once and open more registers, with the warning that the parallel registers only work if each customer is independent.
And you watched it beat with a real local demo: Reservo debuted a slow suite —test_reports.py, twelve tests with half a second of wait each— and you ran the complete suite two ways. Serially: 23 passed in 6.10s. With -n auto: 23 passed in 1.15s, the same suite in a fifth of the time, with the new header 12 workers [23 items]. The module's honesty also became clear: parallelism runs for real locally, the actions/cache YAML is content anchored to pip's Using cached.
Before moving on you should be able to: name CI's two sources of slowness and the lever that attacks each; read the xdist header (N workers [M items]); explain why a slow CI ends up ignored; and distinguish a test that fails always in parallel (isolation, this module) from one that fails sometimes (flaky, module 7).
What's next, in lesson 2, is opening up the human and technical why: what really happens to a team when CI takes too long, how the feedback loop breaks when the wait goes from seconds to minutes, and where exactly the time goes in a run —so the two levers that follow land on the real waste and not on a suspicion.
Resources
- pytest-xdist on PyPI — the official page of the plugin that runs your tests in parallel, with the summary of
-n autoand the distribution options. The installation reference for this module's real demo. - Caching dependencies to speed up workflows — GitHub Actions — the official
actions/cacheguide: what's cached, the key, and why it speeds up. We open it line by line in lesson 3. - How to invoke pytest (pytest documentation) — the reference for the ways to run the suite, including the flags we'll combine with
-nthroughout the module. The starting point for everything we execute here.