Module 5: Fast Ci Caching And Parallelism

3. Caching dependencies with `actions/cache`

Description

In the previous lesson we located the first time sink: installing the dependencies on every run, identically, from scratch, even though requirements.txt hasn't changed in weeks. The runner starts clean —without your libraries—, so each push runs a full pip install: it downloads the packages from the internet, unpacks them, installs them. It's the buffet cook cutting the same vegetables over and over. This lesson provides the remedy: caching the dependencies to reuse them as long as they don't change.

By the end you'll understand what a cache is in the CI context, how actions/cache saves and restores files between runs, and —the heart of the technique— why the cache key is derived from the requirements.txt hash. You'll see how that key makes CI reuse what's installed when the list is the same (cache hit, fast) and reinstall everything when the list changes (cache miss, correct), without you having to manually decide when. You'll read the log format of a successful restoration and a failed one, and you'll anchor all that to something you can see in your own terminal: the Using cached that pip prints when it reuses a package it already downloaded. The cache is the module's cheapest lever —it almost always saves without costing you anything— and it's the first one to turn on.

Connection to the module: this lesson solves sink 1 that lesson 2 diagnosed. It's YAML content —actions/cache only makes sense inside a cloud runner, and we don't have one here—, but anchored to a real local demo: pip's own cache. Lesson 4 attacks sink 2 with parallelism, and that one does run for real. Together, cache and parallelism are the module's two levers. A boundary note: the cache speeds up each cell of module 4's matrix (each version reinstalls its dependencies, so each one gains with its own cache), but you already built the matrix; here we just make it faster.

The buffet's box of "prepped ingredients"

Let's go back to lesson 1's buffet, to the cook who cut the same vegetables every half hour. A smart cook does something obvious: the first time they cut, wash, and prep the vegetables, and store the result in a labeled box in the fridge. The next time they need vegetables, they don't go down to the storeroom or cut again: they open the box and use what they already prepped. They only cut again when the menu changes and different ingredients are needed.

Notice the box's label, because there's all the system's intelligence. The label says, in effect, "vegetables for Tuesday's menu". When the cook goes to cook, they look at today's menu: if it's Tuesday's, the label matches, they open the box and reuse. If the menu changed to Wednesday's, the label no longer matches —"this box is for another menu"—, so they ignore it, cut new ingredients, and store a new box labeled "Wednesday". The label is what guarantees you never use ingredients prepped for a menu different from today's.

A CI cache is exactly that labeled box. The "prepped ingredients" are your already-downloaded dependencies. The "label" is the cache key (key). And "today's menu" is the content of requirements.txt: if your dependency list is the same as last time, the key matches, and CI restores the box instead of reinstalling. If you changed a version or added a library, the key changes, CI ignores the old box and reinstalls from scratch —which is correct, because the "menu" changed—.

A CI cache saves an expensive result (the installed dependencies) under a key. If today's key matches that of a previous run, CI restores the result instead of recomputing it. The key is derived from requirements.txt, so it reuses when the list didn't change and reinstalls when it did.

Anatomy of the cache step

This is how the step that caches pip dependencies looks in a GitHub Actions workflow. It's content —you write it and understand it, no runner runs here—, and it goes before the install step:

- name: Cache pip dependencies
  uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
    restore-keys: |
      ${{ runner.os }}-pip-

Let's break down each line, because each carries a piece of the idea:

uses: actions/cache@v4 — uses GitHub's official cache action, pinned to its major version v4 (the same pinning habit you learned with checkout and setup-python). This action knows how to do two things: at the start of the job, restore a box if the key matches; at the end, save a new box if there wasn't one.

path: ~/.cache/pipwhat gets saved in the box. ~/.cache/pip is the directory where pip stores the packages it downloads on Linux (the default runner). We don't cache the entire installed environment, but the pip download cache: the .whl files pip downloads from the internet. Reusing them turns a "download from the internet + install" into an "install from disk", which is much faster because it skips the network part.

key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} — the box's label, the central piece. It's built from three parts joined by dashes:

  • ${{ runner.os }} — the runner's operating system (Linux, macOS, Windows). It's in the key because one platform's packages don't work on another; you don't want to restore Linux wheels on a Windows runner.
  • pip — a fixed text that identifies which cache this is (you could have others: one for npm, one for data). It's naming hygiene.
  • ${{ hashFiles('**/requirements.txt') }}the hash of requirements.txt's content. This is the magic. hashFiles computes a fingerprint of the file: a long string that changes if any byte of the file changes, and that's identical if the file is identical. As long as requirements.txt doesn't change, this hash is the same, the complete key is the same, and CI finds the box. As soon as you edit the file —bump a version, add a line—, the hash changes, the key changes, and CI no longer finds that box (correctly: the "menu" changed).

restore-keys: — the safety net. If the exact key doesn't exist (for example, you changed requirements.txt and it's the first run with the new list), instead of giving up and downloading everything from the internet, CI looks for the most similar box that starts with ${{ runner.os }}-pip-. It restores that "almost good" box —it has most of your packages already downloaded— and pip only downloads what's missing or changed. It's a partial cache miss: you don't hit the exact box, but you leverage an old one as a starting point instead of starting from scratch.

Why the key is the hash and not something simpler

You might wonder: why not use a fixed key, like key: pip-cache, and be done? The answer is the whole technique's reason for being, and it's worth understanding well.

A fixed key never changes. That means the first run saves a box with your dependencies from that day, and all the following runs restore that same box forever —even if you change requirements.txt—. The day you bump pytest from 9.1.1 to 9.2.0, CI would keep restoring the old box (with 9.1.1 downloaded) and you could end up testing against the wrong version, or with an obsolete box that doesn't have the new package. The cache would become incorrect: fast, but lying.

A key derived from the file's hash solves this at the root: the key is tied to the content of requirements.txt. Same list → same hash → same key → reuse (fast and correct). Different list → different hash → different key → reinstall and save a new box (a bit slower that time, but correct). The hash is what makes the cache automatically valid: it invalidates itself, exactly when it should, without you remembering to clean it. It never reuses ingredients prepped for a menu that already changed.

That's the golden rule of any cache: the key must include everything that, if it changes, should invalidate the result. Since the only thing that decides what gets installed is requirements.txt, hashing it is exactly the correct key. If your dependencies came from several files (requirements.txt plus requirements-dev.txt, say), you'd include them all in the hash: hashFiles('**/requirements*.txt').

The mechanism, locally: pip's Using cached

Here we connect the content with something you do run for real. actions/cache operates at CI scale (it saves files in the cloud between runs), but the principle —"don't download what you already have"— is the same one pip applies on your own machine with its local cache. Seeing it work in your terminal makes the concept tangible.

pip stores each package it downloads in a cache directory. On this machine:

pip cache dir
/Users/mikenieva/Library/Caches/pip

When you install a package pip already downloaded before, it doesn't download it again from the internet: it takes it from there. I installed the Reservo suite in a new and clean virtual environment, with pip already "warm" (it had installed pytest before), and this is the real output:

pip install -r requirements.txt

What to expect:

Collecting pytest==9.1.1 (from -r requirements.txt (line 1))
  Using cached pytest-9.1.1-py3-none-any.whl.metadata (7.6 kB)
Collecting iniconfig>=1.0.1 (from pytest==9.1.1->-r requirements.txt (line 1))
  Using cached iniconfig-2.3.0-py3-none-any.whl.metadata (2.5 kB)
Collecting packaging>=22 (from pytest==9.1.1->-r requirements.txt (line 1))
  Using cached packaging-26.2-py3-none-any.whl.metadata (3.5 kB)
Collecting pluggy<2,>=1.5 (from pytest==9.1.1->-r requirements.txt (line 1))
  Using cached pluggy-1.6.0-py3-none-any.whl.metadata (4.8 kB)
Collecting pygments>=2.7.2 (from pytest==9.1.1->-r requirements.txt (line 1))
  Using cached pygments-2.20.0-py3-none-any.whl.metadata (2.5 kB)
Using cached pytest-9.1.1-py3-none-any.whl (386 kB)
Using cached pluggy-1.6.0-py3-none-any.whl (20 kB)
Using cached iniconfig-2.3.0-py3-none-any.whl (7.5 kB)
Using cached packaging-26.2-py3-none-any.whl (100 kB)
Using cached pygments-2.20.0-py3-none-any.whl (1.2 MB)
Installing collected packages: pygments, pluggy, packaging, iniconfig, pytest
Successfully installed iniconfig-2.3.0 packaging-26.2 pluggy-1.6.0 pygments-2.20.0 pytest-9.1.1

Each Using cached line is pip telling you "I already had this package downloaded, I take it from disk instead of downloading it from the internet". On a totally fresh machine, with nothing in the cache, those lines would say Downloading pytest-9.1.1-py3-none-any.whl (386 kB) with its progress bar, because it would have to download each file. The difference between Downloading and Using cached is, in miniature, exactly what actions/cache does at scale: the first run downloads everything (cache miss, you see Downloading), and the following ones reuse (cache hit, you see Using cached). Pip's cache is local to your machine; actions/cache takes that same cache to the cloud so it persists between CI runs, which would otherwise always start cold.

How to read the cache log in CI

Since the cache step is content, let's see the honest format of how its log would read on a runner, so you recognize it when you set up a real pipeline. There are two scenarios.

Cache hit (the key matches: it found the exact box). In the Cache pip dependencies step, at the start of the job, you'd see something like:

Received 12582912 of 12582912 (100.0%), 12.0 MBs/sec
Cache Size: ~12 MB (12582912 B)
Cache restored successfully
Cache restored from key: Linux-pip-93c4ab5f175d24229e46ec70a8ecaeb6db8b36e3f54d8005808044c060acbc92

Read it: it restored a box of ~12 MB, and the key line is Cache restored from key: Linux-pip-93c4ab5f.... That long hash is the fingerprint of your requirements.txt —the box's label—. After this, the pip install step runs in seconds, because the packages are already in ~/.cache/pip: pip only installs them from disk, doesn't download them.

Cache miss (the key doesn't exist: you changed requirements.txt, or it's the first run). You'd see:

Cache not found for input keys: Linux-pip-a1b2c3d4e5f6..., Linux-pip-

It found neither the exact key nor any of the restore-keys. In that case the step restores nothing, pip install downloads everything from the internet (slower that time), and at the end of the job an automatic step saves the new box:

Cache saved with key: Linux-pip-a1b2c3d4e5f6...

Saved with the new key. The next run, if requirements.txt doesn't change, will be a cache hit and will fly. That's the complete cycle: the first run with a new list pays the price of downloading everything and saves the box; all the following ones with the same list reuse it.

A modern shortcut: setup-python with cache: 'pip'

It's worth mentioning that, for the common case of caching pip dependencies, actions/setup-python comes with built-in caching, and it's shorter to write:

- name: Set up Python
  uses: actions/setup-python@v5
  with:
    python-version: "3.14"
    cache: 'pip'

That cache: 'pip' line does under the hood almost the same as the actions/cache step we broke down: it caches the pip directory and uses the requirements.txt hash as the key, without you writing the path, the key, or the restore-keys. For most projects it's the recommended way, for its simplicity. We learned the manual actions/cache version first because understanding the hash key is what matters: cache: 'pip' is the convenient shortcut, but you only trust a shortcut when you understand what it automates. And for caching things that aren't pip dependencies (data, compiled artifacts), actions/cache with its hand-written key is still the tool.

Common mistakes

Using a fixed key that never invalidates the cache. What happens: someone writes key: pip-cache without the hash, and the cache forever reuses the first box, even if requirements.txt changes. One day they bump a dependency, CI restores the old box without the new package, and the tests fail bafflingly —or worse, pass against the wrong version—. Why it happens: a fixed key looks simpler and "works" at first. How to spot it: if your key doesn't include hashFiles(...) of your dependency file, your cache doesn't invalidate when it should. How to fix it: put the hash in the key (${{ hashFiles('**/requirements.txt') }}), so the box ties to the list's content and renews itself when it changes.

Putting the cache step after the install one. What happens: someone places actions/cache after pip install, and the restoration never helps because everything was already installed by downloading from the internet. Why it happens: the step order matters and it's easy to get wrong if you think "I cache what I installed". How to spot it: if pip install still takes the same with the cache as without it, check the order. How to fix it: the cache step goes before the install one —restore the box first, so pip install leverages it—. (With setup-python and cache: 'pip' this resolves itself, because the order is handled by the action.)

Forgetting runner.os in the key with an OS matrix. What happens: in a matrix that runs on Linux, macOS, and Windows, someone uses a key without ${{ runner.os }}, and CI tries to restore on Windows a box of wheels compiled for Linux, which don't work —or step on each other across platforms—. Why it happens: the cache was tested on a single OS and the problem only appears when you add the OS dimension (module 4). How to spot it: strange install failures that only happen on one matrix platform, or caches that "corrupt" when crossing systems. How to fix it: include ${{ runner.os }} at the start of the key, so each operating system has its own box and never reuses another's.

Exercises

Exercise 1 — Predict hit or miss. For each situation, say whether the cache step with key ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} would produce a cache hit or a cache miss, and why in one sentence. (a) A second push that doesn't touch requirements.txt. (b) A push that bumps pytest==9.1.1 to pytest==9.2.0 in requirements.txt. (c) The repository's first push. (d) A push that only changes an app .py file, without touching requirements.txt.

See solution
  • (a) Cache hit. requirements.txt didn't change, so its hash is the same, the key is the same, and CI finds the box from the previous run. Reuses: fast.
  • (b) Cache miss. Changing pytest==9.1.1 to 9.2.0 changes the file's content, so the hash changes and the key too. There's no box with that new key → miss → reinstalls and saves a new box. Correct: the "menu" changed.
  • (c) Cache miss. On the first push there's no box yet. Mandatory miss; it downloads everything, and saves the first box for the following runs.
  • (d) Cache hit. Changing a .py doesn't touch requirements.txt, so its hash is identical and the key matches. The dependencies cache is still valid —the dependencies didn't change, only your code—: hit.

The moral: the key is tied only to requirements.txt, so only changes to the dependency list invalidate the cache. Changing your code doesn't affect it, which is exactly what you want.

Exercise 2 — Fix the broken key. A teammate has this step and complains that "the cache never updates even when I change the dependencies". Find the defect and fix it.

- name: Cache pip
  uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: pip-dependencies
See solution

The defect is the fixed key: key: pip-dependencies never changes. The cache saves the first box and reuses it forever, no matter how many times requirements.txt is edited —hence "it never updates"—. It's missing the dependency file's hash in the key.

Fixed:

- name: Cache pip
  uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
    restore-keys: |
      ${{ runner.os }}-pip-

Now the key includes ${{ hashFiles('**/requirements.txt') }}: the file changes → the hash changes → the key changes → cache miss → reinstalls and saves a new box. We also added ${{ runner.os }} (in case one day there's an OS matrix) and restore-keys as a safety net to leverage a partial box when the exact one doesn't exist. The cache now invalidates itself, exactly when the dependencies change.

Exercise 3 — Translate the local Using cached to the CI log. In your terminal, pip install -r requirements.txt printed Using cached pytest-9.1.1-py3-none-any.whl (386 kB) for the five dependencies. Explain which CI scenario (cache hit or cache miss) this resembles, which CI log line would be its equivalent, and what you'd have seen in your terminal if the scenario were the opposite.

See solution

Using cached in your terminal resembles a cache hit in CI: pip found the packages already downloaded in its local cache and reused them instead of downloading them, just as actions/cache would restore the dependency box instead of downloading them from the internet. Its equivalent in the CI log would be the line Cache restored from key: Linux-pip-93c4ab5f... (plus the Cache restored successfully): the box was restored, and that's why the pip install that follows doesn't have to download anything.

If the scenario were the opposite —a cache miss, the machine totally cold—, your terminal would have shown Downloading pytest-9.1.1-py3-none-any.whl (386 kB) with a progress bar, instead of Using cached, because it would have to download each file from the internet. And in the CI log you'd have seen Cache not found for input keys: ... at the start, and Cache saved with key: ... at the end, when it saves the new box for next time.

The lesson: Using cached (local) and Cache restored from key (CI) are the same idea at two scales —reusing what's already downloaded—; Downloading (local) and Cache not found (CI), too.

Summary and next step

In this lesson you turned on the module's first lever: caching the dependencies so as not to reinstall them identically on every run. You saw it with the buffet's labeled ingredient box: prep the vegetables once, store them with a label, and reuse them as long as the menu doesn't change. You broke down the actions/cache step —path (what's saved: the pip cache), key (the label), and restore-keys (the safety net)— and understood the technique's heart: the key is derived from ${{ hashFiles('**/requirements.txt') }}, so it reuses when the list is the same (cache hit) and reinstalls when it changes (cache miss), invalidating itself exactly when it should. A fixed key would be fast but would lie; the hash makes it automatically valid.

You anchored it all to a real local demo —pip's Using cached, the same principle at small scale— and learned to read the CI log format: Cache restored from key: ... on a hit, Cache not found followed by Cache saved with key: ... on a miss. And you saw the modern shortcut, setup-python with cache: 'pip', which automates the manual version once you understand what it automates.

Before moving on you should be able to: explain what a CI cache is and why its key is derived from the requirements.txt hash; write a correct actions/cache step and say why it goes before the pip install; predict hit or miss based on what changed; and connect pip's Using cached with CI's Cache restored from key.

What's next, in lesson 4, is the second lever, and this one runs for real: pytest-xdist with -n auto. You're going to distribute the Reservo suite across your processor's cores and see the real speedup with your own eyes —from 6 seconds to a little over one—, read pytest's new header (12 workers [23 items]), and understand how -n auto chooses how many processes to start. You cached to not repeat; now you're going to parallelize to not run in a line.

Resources