# How to Cache uv Dependencies in CI


A CI job that reinstalls every dependency from scratch spends most of its time downloading wheels it already had. [uv](https://pydevtools.com/handbook/reference/uv.md) keeps those wheels in a package store outside the project, and caching that store is the whole optimization.

## Prerequisites

* A project with a committed `uv.lock` ({{< term "lockfile" >}})
* A CI job that runs `uv sync` or `uv run`

## Cache uv's package store

uv unpacks downloaded {{< term "wheel" "wheels" >}} into the store that `uv cache dir` reports: `~/.cache/uv` on Linux and macOS, `%LOCALAPPDATA%\uv\cache` on Windows. That is the directory worth caching.

On GitHub Actions, the `setup-uv` action handles it:

```yaml {filename=".github/workflows/ci.yml"}
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d  # v10.0.1
  with:
    enable-cache: true
- run: uv sync --locked
- run: uv run pytest
```

The action keys the cache on `uv.lock`, so a dependency change invalidates it. Get the current SHA from the [setup-uv releases page](https://github.com/astral-sh/setup-uv/releases), following [how to pin GitHub Actions by SHA](https://pydevtools.com/handbook/how-to/how-to-pin-github-actions-by-sha-for-python-projects.md).

On other CI systems, cache the same directory by hand. Ask uv where it is instead of hardcoding a path:

```bash
uv cache dir
```

Docker builds reuse the store through a BuildKit cache mount instead, covered in [how to use uv in a Dockerfile](https://pydevtools.com/handbook/how-to/how-to-use-uv-in-a-dockerfile.md).

## Cache managed interpreters only when uv downloads them

uv installs the Python versions it manages into the directory `uv python dir` reports (`~/.local/share/uv/python` on Linux and macOS, `%APPDATA%\uv\python` on Windows). One CPython install runs about 95 MB on Linux x86_64.

It buys nothing when the job uses a Python that the runner already provides. A `uv sync` that picks up `/usr/local/bin/python3.12` never creates that directory at all.

`setup-uv` covers the case with a separate input, off by default:

```yaml
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d  # v10.0.1
  with:
    enable-cache: true
    python-version: "3.13"
    cache-python: true
```

## Leave `.venv` out of the cache

Caching the {{< term "virtual-environment" "virtual environment" >}} looks like the next win, but a restored `.venv` and the lockfile can disagree with nothing to tell you.

Plain `uv run` syncs *inexactly*: it installs what the lockfile requires and leaves anything extra alone. A cache restored from before a dependency was removed keeps that package on disk, and it keeps importing:

```console
$ uv remove packaging
Resolved 6 packages in 3ms
Uninstalled 1 package in 1ms
 - packaging==26.3

$ # CI restores a .venv cached before the removal, then runs the tests
$ uv run python -c "import packaging; print(packaging.__version__)"
26.3
```

`packaging` is gone from `pyproject.toml` and `uv.lock`, and it still imports. A test that depends on it passes in CI and fails for the next person who clones the repository. `uv run --locked` does not help, because the lockfile is not what drifted.

Rebuilding costs little once the store is cached. A 28-package project rebuilt its `.venv` in 0.19s against a warm cache.

## Shrink the cache before it is saved

Restoring a pre-built wheel from the CI cache is often slower than downloading it again. `uv cache prune --ci` drops pre-built wheels and unzipped source distributions, keeping wheels the job built from source:

```console
$ uv cache prune --ci
Pruning cache at: .cache/uv
Removed 3588 files (135.8MiB)
```

That took a 137 MB cache down to 8.5 KB for a project whose dependencies all ship pre-built wheels. Run it as the job's last step, before the cache is written; on GitHub Actions, `prune-cache: true` does it for you.

Projects that compile a dependency from source are the exception. There the built wheels are what the cache exists to preserve, and pruning keeps them.
