# Cache pip GitHub Actions installs, and Poetry, with numbers

> Cache pip GitHub Actions installs with setup-python, and why the Poetry virtualenv beats the download cache, measured cold against warm on a runner.

Source: https://latchkey.dev/learn/speed/cache-pip-and-poetry-in-github-actions  
Updated: 2026-09-20

To cache pip GitHub Actions installs you add `cache: pip` to `actions/setup-python`, and on a fast runner that is worth well under a second, because it caches downloads and not the install. The number that moves is elsewhere: a package with no wheel for your platform took 10.9 seconds to build against 0.5 seconds to install, and that build lands in the same cache.

Python dependency caching has a reputation it only half deserves. The advice everyone gives, turn on the setup action's cache option, is correct and it is also the smaller half of the story, because what that option keeps is the download and what costs you time is usually the install.

The distinction matters as soon as one of your dependencies has no wheel for the runner's platform and pip has to compile it. That is where a cached directory stops saving you hundreds of milliseconds and starts saving you tens of seconds, and it is also why the Poetry path, which can restore the finished virtualenv, behaves differently from the pip path.

## What setup-python caches, and what it does not

The action defaults to finding a dependency file, `requirements.txt` or `pyproject.toml` for pip, `Pipfile.lock` for pipenv, `poetry.lock` for poetry, and uses its hash as part of the cache key. For pip it caches the global cache directory, which on Linux is `~/.cache/pip`. For poetry it caches the virtualenv directories instead, one for each Poetry project it finds.

That is the whole difference in one line. The pip option gives you back the network; the poetry option gives you back the install. An open request on the action asks for exactly this, "PIP cache should cache the installed packages as well", and it has been open since February 2022 with 34 comments, which is a reasonable proxy for how often people hit the gap.

| `cache:` value | Directory kept | Keyed on by default | What that skips |
| --- | --- | --- | --- |
| `pip` | `~/.cache/pip` | `requirements.txt` or `pyproject.toml` | Downloading, and rebuilding any wheel pip built before |
| `poetry` | The project virtualenv directories | `poetry.lock` | Downloading and installing both |
| `pipenv` | The virtualenv | `Pipfile.lock` | Downloading and installing both |

> Quoted from the `actions/setup-python` documentation on GitHub, read on 2026-09-20. `cache-dependency-path` overrides the files hashed into the key, which is what you need when the dependency files live in subdirectories.

## The download cache, measured

We installed the same 26 pinned packages into a freshly created empty virtualenv on a Latchkey `latchkey-small` runner, once with `~/.cache/pip` deleted and then with it in place. Nothing else moved between the rows: same lockfile, same runner, same command, same interpreter, and the virtualenv was created outside the timed region so no row paid for that.

Cold took 7.7 seconds and warm took 6.9. That is 0.8 seconds, about a tenth of the step, and running with `--no-cache-dir` came back at 7.0 seconds, which tells you the same thing from the other side: on this runner the downloads are cheap and the unpacking is not. The cache entry itself was 15 MB across 156 files, against a finished virtualenv of 77 MB.

| Install, control held: same 26 pinned packages into a new empty venv | Duration |
| --- | --- |
| `pip install -r requirements.txt`, `~/.cache/pip` emptied | 7.7 s |
| `pip install -r requirements.txt`, `~/.cache/pip` restored | 6.9 s |
| `pip install -r requirements.txt`, restored, second pass | 7.0 s |
| `pip install --no-cache-dir -r requirements.txt` | 7.0 s |
| Size of the pip cache entry after the cold install | 15 MB, 156 files |
| Size of the installed virtualenv | 77 MB |

> Measured by `job-h.sh`, under content/repro/timings/cache-pip-and-poetry-in-github-actions/, on a Latchkey `latchkey-small` runner on 2026-09-20: 2 vCPU, 7,734 MB RAM, Python 3.12.3, pip 24.0. One pass per row. The pinned set was produced by a `pip freeze` earlier in the same job, so every row installed identical versions.

## Wheels against source: the row that changes the argument

A wheel is a built artifact you unpack. An sdist is source you compile. When PyPI has a wheel matching the runner's Python version, operating system and architecture, pip downloads it and unpacks it; when it does not, pip builds one, and that build is the thing worth caching.

We installed one small package both ways with the cache bypassed on both sides, so the pair isolates the format and nothing else. From its wheel it took 0.5 seconds. Forced to build from the sdist with `--no-binary :all:` it took 10.9 seconds, about twenty times longer, for a package whose C extension is a few hundred lines. Scale that to a scientific stack and the wheel question stops being an optimization.

Here is the part people miss: the wheels pip builds go into the same cache directory `cache: pip` restores, and pip documents that when a cached wheel exists for that version of that package it uses it instead of rebuilding the project. So the download cache is worth under a second on a project with wheels for everything, and it is worth the whole build on a project without them. Check which one you are before deciding the cache is not worth configuring.

| One package, control held: cache bypassed on both rows | Duration |
| --- | --- |
| `pip install MarkupSafe`, from the published wheel | 0.5 s |
| `pip install --no-binary :all: MarkupSafe`, built from the sdist | 10.9 s |

> Same job and same runner as the table above. Both rows ran with `--no-cache-dir`, so neither benefited from anything the earlier rows left behind.

## Poetry: cache the virtualenv, not the downloads

Poetry resolves from `poetry.lock` and installs into a virtualenv it manages. Point it at an in-project `.venv` and the setup action can restore that directory wholesale, which is the version of caching that removes the install rather than shortening it.

We measured the same `poetry install --no-root` three ways on the same locked project: with nothing cached, with the download cache warm and no virtualenv, and with the virtualenv restored. It went 1.9 seconds, then 1.4, then 0.7. The restored virtualenv was 37 MB and the download cache 18 MB, so the bigger saving is also the bigger thing to store, which is the trade the 10 GB repository cap eventually makes you think about.

| `poetry install --no-root`, control held: same `poetry.lock`, same runner | Duration |
| --- | --- |
| Nothing cached | 1.9 s |
| Download cache warm, no virtualenv | 1.4 s |
| Virtualenv restored, which is what `cache: poetry` keeps | 0.7 s |
| Size of `~/.cache/pypoetry` | 18 MB |
| Size of the in-project `.venv` | 37 MB |

> Same job, same runner, Poetry 2.5.1 and 21 locked packages. These rows ran after the pip rows in the same job, so the machine and the network path to PyPI were already warm; read the three against each other and not against the pip table, whose project has 26 packages and a different resolver.

## The workflow, both ways

For pip, two lines on the setup action do it. Keep the install step unconditional afterwards: a restored cache is a head start, not a substitute for resolving against your requirements file.

For Poetry there is one extra step, because the action needs Poetry to exist before it can find the virtualenv to cache. Install Poetry first, tell it to keep the virtualenv in the project, then set up Python with `cache: poetry`. Doing it in the other order caches nothing and gives you no error to explain why.

```.github/workflows/ci.yml
# pip
- uses: actions/setup-python@v7
  with:
    python-version: '3.12'
    cache: pip
- run: pip install -r requirements.txt

# poetry: install it first, or there is no virtualenv to find
- run: pipx install poetry
- run: poetry config virtualenvs.in-project true
- uses: actions/setup-python@v7
  with:
    python-version: '3.12'
    cache: poetry
- run: poetry install --no-interaction
```

## What we ran, so you can disagree with it

One script, `job-h.sh`, run once on a Latchkey `latchkey-small` runner on 20 September 2026, committed under content/repro/timings/cache-pip-and-poetry-in-github-actions/. The script, the unedited `job-h.log` and a `job-h.status.json` carrying the job id, the runner size and the exit code are committed together, so the digits above can be checked.

The caveats are the ones one pass always carries. A gap under about half a second is inside the noise, which is why the two warm pip rows and the `--no-cache-dir` row are reported as the same number rather than as a ranking. The runner sits close to PyPI, so the download share of a cold install here is at the low end of what a slower network would show, and that biases against the download cache rather than for it. The three Poetry rows ran late in the same job and the two tools resolved different package counts, so nothing on this page compares pip with Poetry directly.

## FAQ

### Does the setup-python pip cache include the installed packages?

No. It caches the pip download directory, which on Linux is `~/.cache/pip`, and your job still runs the install afterwards. An open request on the action, "PIP cache should cache the installed packages as well", has been asking for the other behavior since 2022. If you want the install skipped rather than shortened, use Poetry or pipenv, whose cache options keep the virtualenv.

### Why does poetry install pick the wrong Python version after a cache restore?

Because a restored virtualenv is bound to the interpreter that created it, and if Poetry was installed against the system Python it will keep using that one. Install Poetry in isolation with `pipx`, set `virtualenvs.in-project`, and put the setup-python step after it, so the version the action selects is the version Poetry builds the virtualenv against.

### Should I cache the virtualenv directly with actions/cache?

You can, and it is the fastest option, but key it on the lockfile and the exact Python version and never fall back to a loose prefix. A virtualenv contains absolute paths and compiled extensions, so a partial restore from a near-miss key gives you an environment that imports until it does not. The download cache fails safe; a virtualenv cache does not.

### How much cache does a Python project use against the 10 GB limit?

On the project measured here, 15 MB for the pip download cache, 18 MB for the Poetry download cache and 37 MB for the virtualenv. Small by itself, and the number that matters is that multiplied by your matrix, because every Python version and every operating system you test on writes its own entry.

## References

- [actions/setup-python: caching packages dependencies (verified 2026-09-20)](https://github.com/actions/setup-python)
- [pip documentation: caching, and the locally built wheel cache (verified 2026-09-20)](https://pip.pypa.io/en/stable/topics/caching/)
- [actions/setup-python issue 330: cache the installed packages as well (verified 2026-09-20)](https://github.com/actions/setup-python/issues/330)
- [GitHub Docs: caching dependencies to speed up workflows (verified 2026-09-20)](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
