Your CLI's test suite takes twenty seconds, but every CI job takes two minutes, because each one starts from an empty machine and downloads, unpacks and sometimes compiles every dependency before running a single test. Multiply by a nine-job matrix and a busy day of pull requests and the pipeline becomes the thing people wait on. uv is already fast at installing, but the fastest install is one that downloads nothing. This guide covers caching uv's package cache between CI runs so installs become near-instant — with the right cache key so it is never stale, pruning so it stays small, and equivalents for GitLab CI and Docker builds. It is part of the CI/CD pipelines topic.
Prerequisites
- A CLI project managed with uv, with
uv.lockcommitted. See uv for Python CLI dependency management. - A CI pipeline that installs with
uv sync, such as the matrix in testing a CLI across Python versions with GitHub Actions.
What there is to cache
uv keeps a global cache directory (on Linux runners, ~/.cache/uv by default) holding downloaded wheels, unpacked archives, wheels it built from source distributions, and resolver metadata. A project's .venv is then populated by linking or copying files out of that cache. Caching the virtual environment itself is tempting but brittle — it embeds absolute paths and interpreter details, and a stale one fails in confusing ways. Caching uv's cache directory is robust: on a hit, uv sync still builds a fresh environment, it just finds every file it needs locally.
The recipe: GitHub Actions
astral-sh/setup-uv has caching built in. For most projects, one flag is the whole setup:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
python-version: ${{ matrix.python }}
- run: uv sync --locked --group dev
- run: uv run pytest -q
With enable-cache: true, the action restores the cache at the start of the job and saves it at the end, using a key built from the runner OS, the architecture, the Python version and a hash of the dependency files — by default uv.lock, falling back to requirements*.txt and pyproject.toml patterns. At the end of the job it also runs uv cache prune --ci, which removes pre-built wheels (fast to re-download) and keeps packages uv had to build from source (slow to rebuild). That keeps the cache small enough to save and restore in seconds.
Two settings are worth knowing:
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
cache-dependency-glob: |
uv.lock
packages/*/pyproject.toml
cache-suffix: ${{ matrix.resolution }}
cache-dependency-glob changes which files feed the key — useful in a workspace with several pyproject.toml files, as described in uv workspaces for multi-package CLIs. cache-suffix separates caches for jobs that install different dependency sets from the same lockfile, such as a lowest-versions job; otherwise two different sets would fight over one key.
Choosing the key
The key decides both hit rate and correctness. Keying on the lockfile hash is exact: the cache is reused until dependencies change, then rebuilt.
Wheels are platform- and version-specific, so the OS and Python version must be in the key too (setup-uv includes them). If you manage the cache yourself with actions/cache, reproduce that shape:
- uses: actions/cache@v4
with:
path: ~/.cache/uv
key: uv-${{ runner.os }}-${{ runner.arch }}-py${{ matrix.python }}-${{ hashFiles('uv.lock') }}
restore-keys: |
uv-${{ runner.os }}-${{ runner.arch }}-py${{ matrix.python }}-
- run: uv sync --locked --group dev
- run: uv cache prune --ci
The restore-keys fallback lets a job whose lockfile just changed start from the previous cache; most packages will still be there, and only the changed ones are downloaded.
--locked: fast and correct
uv sync --locked installs exactly what uv.lock records and fails if the lockfile does not match pyproject.toml. That matters for caching because it makes the lockfile the single source of truth for both the key and the install — a job can never quietly re-resolve to different versions than the cache was built from. It also catches the "edited dependencies, forgot to re-lock" mistake in the first CI job.
Other CI systems
The same idea — cache uv's directory, keyed on the lockfile, prune before saving — carries over. In GitLab CI:
# .gitlab-ci.yml
variables:
UV_CACHE_DIR: .uv-cache
UV_LINK_MODE: copy
test:
image: ghcr.io/astral-sh/uv:python3.13-bookworm-slim
cache:
key:
files: [uv.lock]
paths: [.uv-cache]
script:
- uv sync --locked --group dev
- uv run pytest -q
after_script:
- uv cache prune --ci
GitLab only caches paths inside the project directory, hence UV_CACHE_DIR. UV_LINK_MODE=copy avoids a warning (and a fallback) when the cache and the virtual environment sit on different filesystems, which is common in containerised runners where hard links are not possible.
Docker builds
If your CLI ships as a container image, the Docker build is often the slowest step of all. BuildKit cache mounts give uv a persistent cache directory across builds without putting it in the image:
# syntax=docker/dockerfile:1
FROM python:3.13-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
ENV UV_LINK_MODE=copy UV_COMPILE_BYTECODE=1
WORKDIR /app
# Dependencies first: this layer is reused until uv.lock changes.
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-install-project --no-dev
# Then the project itself.
COPY src ./src
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
ENTRYPOINT ["/app/.venv/bin/mytool"]
Installing dependencies in a layer that only depends on pyproject.toml and uv.lock means editing your source code does not invalidate it. --no-install-project installs everything except your own package, which comes in the next layer. In CI, pair this with BuildKit's registry or GitHub Actions cache backends (docker/build-push-action with cache-from: type=gha) so layers survive between runs.
UX considerations
For the people waiting on the pipeline, caching is only half the story:
- Show what the cache did.
setup-uvlogs whether it restored a cache and the key it used; keep that visible when debugging slow runs rather than hiding it behind a quiet flag. - Prefer pre-built wheels. If one dependency always builds from source because it has no wheel for your Python version, that is often the slowest step even with a cache. Pinning a version that has wheels, or bumping the matrix's Python only when wheels exist, can save more than any cache tuning.
- Do not cache across trust boundaries. Caches written by pull-request workflows from forks can be read by later runs in some configurations. GitHub scopes caches by branch, with the default branch's caches readable by all; avoid saving caches from untrusted workflows into scopes that release jobs use.
- Keep release builds honest. Caching speeds up installs but never replaces building the artefact from a clean checkout. The build job in a release pipeline should still start from source, as in smoke-testing the built wheel in CI.
Testing the behaviour
Verify caching the same way you would verify any optimisation: measure before and after, and make sure correctness did not change.
- Compare install step durations on two consecutive runs of the same commit. The second should show a cache hit in the
setup-uvlog and an install step measured in seconds. - Change a dependency and push. The key changes, the cache misses (or partially restores from
restore-keys), and the new cache is saved at the end. The following run should hit again. - Delete the cache from the repository's Actions → Caches page (or with
gh cache delete) when you suspect it is corrupt; a correct setup recovers on the next run with no other changes. - Check the size.
du -sh ~/.cache/uvbefore and afteruv cache prune --ciin a debug step shows whether pruning is working. Caches of a few tens of megabytes restore in a couple of seconds; multi-gigabyte caches cost more time than they save.
Locally, uv cache dir shows where uv keeps its cache and uv cache clean resets it — useful when reproducing a CI install from scratch.
Conclusion
Cache uv's package cache, not the virtual environment; key it on the lockfile plus OS and Python version; install with uv sync --locked so the lockfile governs everything; and prune with --ci so only source-built packages are kept. On GitHub Actions that is one enable-cache: true; on GitLab and in Docker it is a cache path and a mount. The result is CI installs that take seconds, with no risk of testing against a stale dependency set.
Frequently asked questions
Should I also cache the .venv directory?
Usually not. With a warm uv cache, creating the environment takes a second or two, and a cached .venv can break subtly when the runner image's Python changes. If you do cache it, include the exact interpreter version in the key and still run uv sync --locked afterwards.
Why is my cache never hit?
Check the key in the logs of two consecutive runs. Common causes: the lockfile is regenerated during the job (use --locked), the key includes something that changes every run such as a timestamp, or the cache is saved from a different branch scope than the one reading it.
Does caching work with uv tool install in smoke tests?
Yes — tool installs use the same cache directory. The benefit is smaller there, because a smoke test installs your wheel plus its runtime dependencies only once per job.
How much time does this really save?
It depends on your dependency tree. Projects whose dependencies all ship wheels see modest savings, since uv downloads quickly; projects with source-built dependencies or large packages see the largest gains. Measure on your own pipeline before and after.