How Do I Cache uv or Poetry Environments?

Cache the uv or Poetry cache directory keyed on the lockfile and let the tool rebuild the virtual environment from it. A cached .venv breaks on upgrades.

Answer

Cache the tool's own cache directory, key it on the lockfile, and let uv sync or poetry install rebuild the virtual environment from that cache on every job. The virtual environment itself stays out of the cache, because a .venv records the absolute path of the interpreter that created it and stops working the moment the runner image ships a new Python patch build.

That gives two paths on WarpBuild runners. Poetry has a supported input on the Python setup action; uv gets an explicit cache step, because the cache traffic has to be pointed at WarpBuild Cache rather than GitHub Actions Cache.

- uses: WarpBuilds/setup-python@v6
  with:
    python-version: '3.12'
    cache: poetry
    cache-dependency-path: poetry.lock
- uses: astral-sh/setup-uv@v5
  with:
    enable-cache: false

- uses: WarpBuilds/cache@v1
  with:
    path: ~/.cache/uv
    key: uv-${{ runner.os }}-${{ runner.arch }}-py3.12-${{ hashFiles('uv.lock') }}

WarpBuilds/setup-python and WarpBuilds/cache@v1 are drop-in replacements for their upstream counterparts and take the same inputs, documented in the WarpBuild setup actions reference and the WarpBuild caching documentation. The enable-cache: false line on the uv setup action matters: left on, it stores the same directory through GitHub Actions Cache, and you end up paying for two copies of the same bytes.

ToolDefault cache directoryVariable that pins itFile that keys itSync command that fails on drift
uv~/.cache/uvUV_CACHE_DIRuv.lockuv sync --locked
Poetry~/.cache/pypoetryPOETRY_CACHE_DIRpoetry.lockpoetry check --lock then poetry install

Set the environment variable rather than relying on the default. Both tools resolve the default differently on macOS and on Windows, and a cache step whose path does not match the directory the tool actually used restores nothing and reports success. Pinning the directory makes the path input correct on every platform in the matrix.

Detail

Key on the lockfile, and fail the job when the lockfile has drifted

Hash uv.lock or poetry.lock, never pyproject.toml. The manifest carries version ranges, so two runs with identical manifests can resolve to different package sets, and the cache key would call them the same. The lockfile carries the resolved versions that determine what the cache holds.

The matching install command is the one that refuses to re-resolve. uv sync --locked errors when uv.lock is stale against pyproject.toml instead of quietly writing a new lockfile mid-job, which is documented in the uv repository. poetry check --lock reports the same drift for Poetry before poetry install runs, per the Poetry repository. Without one of those, a developer who edits pyproject.toml and forgets to relock gets a green build that installed something no lockfile describes, and a cache entry keyed on a lockfile the job did not honor.

Put the interpreter version in the key as well. A cache written under Python 3.12 holds wheels tagged cp312, and a 3.13 job that restores it downloads everything again while carrying the dead weight of the 3.12 wheels.

Why a cached virtual environment breaks on a Python patch bump

A virtual environment is a directory of absolute paths. pyvenv.cfg records the home directory of the interpreter that created it, bin/python is a symlink into that directory, and console scripts under bin/ carry a shebang line with the full path baked in.

Runner images and setup actions move that path on a patch upgrade. An environment created against a 3.12.7 install and restored onto a runner carrying 3.12.9 finds a bin/python symlink pointing at a directory that no longer exists, and every subsequent step fails with a missing-file error rather than a clean cache miss. That failure mode is worse than a cold install, because the cache appears to have hit.

The tool caches do not have that problem. uv stores unpacked distributions and links them into a fresh .venv on each sync, so a warm cache makes environment creation local filesystem work with no network. Poetry stores downloaded artifacts and repository metadata in its cache directory and installs from there. In both cases the environment is disposable and the cache is durable, which is the split described for other ecosystems in the guide to persistent caches on GitHub Actions.

One Poetry-specific trap follows from this. Poetry's default virtual environment location sits inside its own cache directory, at {cache-dir}/virtualenvs. A hand-rolled cache step on ~/.cache/pypoetry therefore drags the environment back into the cache and reintroduces the exact failure above. Two fixes work:

  • Run poetry config virtualenvs.in-project true so the environment lands in .venv in the workspace and is discarded with the job.
  • Or restrict the cached paths to ~/.cache/pypoetry/artifacts and ~/.cache/pypoetry/cache.

The full workflow for both tools

name: test
on:
  push:
    branches: [main]
  pull_request:

env:
  UV_CACHE_DIR: /home/runner/.cache/uv
  POETRY_CACHE_DIR: /home/runner/.cache/pypoetry

jobs:
  uv:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v4

      - uses: astral-sh/setup-uv@v5
        with:
          enable-cache: false

      - uses: WarpBuilds/cache@v1
        with:
          path: ${{ env.UV_CACHE_DIR }}
          key: uv-${{ runner.os }}-${{ runner.arch }}-py3.12-${{ hashFiles('uv.lock') }}
          restore-keys: |
            uv-${{ runner.os }}-${{ runner.arch }}-py3.12-

      - run: uv sync --locked
      - run: uv run pytest -q
      - run: uv cache prune --ci

  poetry:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v4

      - uses: WarpBuilds/setup-python@v6
        with:
          python-version: '3.12'
          cache: poetry
          cache-dependency-path: poetry.lock

      - run: pipx install poetry
      - run: poetry config virtualenvs.in-project true
      - run: poetry check --lock
      - run: poetry install --no-interaction
      - run: poetry run pytest -q

Two details carry most of the value. The restore-keys prefix lets a job whose lockfile just changed start from the previous entry and fetch only the changed packages, which is where a prefix earns its place on a lockfile that moves weekly. And uv cache prune --ci drops pre-built wheels that are cheap to fetch again while keeping wheels that were compiled from source on the runner, which is the expensive half; the setup-uv action runs the same command in its own post step for that reason.

One platform note. WarpBuild caching is not supported on Windows runners, as stated in the caching documentation. A cross-platform Python matrix should expect the Windows leg to resolve and install cold on every run and should be sized on that basis. Sizing and matrix layout for the rest of the legs are covered on the Python on GitHub Actions solution page.

What the cache costs against what it removes

Cache work bills separately from runner minutes on hosted runners and is free on BYOC:

ItemHosted rateUnitBYOC
Cache storage$0.20per GB-monthFree
Cache write, restore, or list$0.0001per operationFree
Snapshot restore$0.04per jobFree
Snapshot storage$0.025per snapshot-hourFree

Worked model for a service repository on uv. Take a 500 MB cache after uv cache prune --ci, 2,000 jobs per month, one restore per job, and 60 writes per month from lockfile bumps.

  • Storage: 0.5 GB x $0.20 = $0.10 per month.
  • Operations: (2,000 restores + 60 writes) x $0.0001 = $0.21 per month.
  • Cache bill: $0.31 per month.

Now the minute side. Read your own step durations out of the job log; the durations below are placeholders for the arithmetic. Say a cold uv sync takes 55 seconds and a warm one takes 8 seconds. That is 47 seconds per job, or about 1,567 minutes across 2,000 jobs.

LineRate per minute1,567 minutesSource
warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB)$0.008$12.54WarpBuild pricing page
GitHub-hosted 4-core Linux larger runner (4 vCPU, 16 GB)$0.012$18.80GitHub list price, checked on 2026-08-13

GitHub list prices come from the GitHub Actions minute multipliers reference, checked on 2026-08-13. Both shapes are 4 vCPU and 16 GB, so warp-ubuntu-latest-x64-4x at $0.008 per minute against $0.012 per minute is 33 percent lower list price. Either way, a $0.31 cache bill removes more than $12 of runner minutes, which is the shape of the trade at most repository sizes.

Every rate above is on the WarpBuild pricing page.

When a snapshot beats a package cache

If the job stays slow with a warm cache, because the toolchain install or a source build lands on every run, price a snapshot against it. Snapshot runners capture a runner VM mid-workflow and boot later jobs from that snapshot, and they sit alongside remote Docker builders, CI observability, an MCP server, and the Action Debugger in the WarpBuild product surface.

At 2,000 jobs per month, snapshot restores cost 2,000 x $0.04 = $80.00 and one snapshot held for a 730 hour month costs 730 x $0.025 = $18.25, or $98.25 in total and about $0.049 per job. At $0.008 per minute on warp-ubuntu-latest-x64-4x, that pays for itself once the snapshot removes more than about 6.1 minutes of setup per job. Below that line, the uv or Poetry cache alone is the cheaper instrument.

Should I cache the virtual environment or the tool cache?

Cache the tool cache. uv keeps wheels and unpacked distributions in its cache directory and links them into a fresh .venv on every sync, and Poetry keeps downloaded artifacts in its own cache directory, so a warm cache turns installation into local filesystem work. A cached .venv records the absolute path of the interpreter that created it, which is what breaks after a Python patch bump. The guide on persistent caches on GitHub Actions covers the same split for other languages.

Which file should key the cache for uv and Poetry?

uv.lock for uv and poetry.lock for Poetry. Hash the lockfile rather than pyproject.toml, because pyproject.toml carries ranges and the lockfile carries the exact resolved versions that determine what lands in the cache. The answer on caching pip dependencies covers the equivalent choice for requirements files and the cache-dependency-path input.

Does the same approach work for conda environments?

No. conda resolves and links packages into a named environment directory rather than installing from a per-project lockfile by default, so the cached unit is the package cache under the conda install plus the environment file hash. The conda on GitHub Actions solution page covers that layout, including the environment.yml key and the solver step that dominates a cold run.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.