Using uvx in GitHub Actions in a Cache-Friendly Way: A Product Builder's Guide
TL;DR
- uvx enables ephemeral Python tool execution in GitHub Actions without polluting your project dependencies, but naive usage can slow down workflows by re-downloading packages on every run.
- Cache the uv cache directory (
~/.cache/uvon Linux/macOS,~/AppData/Local/uv/cacheon Windows) usingactions/cache@v4with a composite key based on your tool requirements to achieve dramatic speedups in subsequent runs. - Simon Willison's approach demonstrates a practical pattern: run uvx commands normally, but wrap them in GitHub's caching layer to persist the uv package cache across workflow executions.
- For product teams, this pattern reduces CI/CD feedback loops, cuts costs on GitHub Actions minutes, and creates a more sustainable development velocity without architectural complexity.
If you're building AI products or any modern software that relies on Python tooling, you've likely felt the friction of slow CI/CD pipelines. Every minute your GitHub Actions workflow spends downloading dependencies is a minute your team isn't shipping features, fixing bugs, or validating hypotheses. This isn't just a developer experience problem—it's a business velocity problem.
The emergence of uv and its companion tool uvx has fundamentally changed how we think about Python dependency management. But as with any new tool, the first-pass implementation often leaves performance on the table. Today, I want to walk through a specific optimization pattern that can cut your GitHub Actions execution time significantly: using uvx in a cache-friendly way.
The uvx Value Proposition
Before we dive into caching strategies, let's establish why uvx matters for product builders. Traditional Python tooling has always had a dependency management problem. If you want to run a tool like black or ruff in your CI pipeline, you typically have two options:
- Add it to your project dependencies (bloating your production environment with dev tools)
- Install it globally in your CI environment (creating potential version conflicts and reproducibility issues)
uvx solves this elegantly by providing ephemeral tool execution. When you run uvx black ., it:
- Downloads black and its dependencies into an isolated environment
- Executes the command
- Leaves no trace in your project's dependency tree
This is conceptually similar to npx in the Node ecosystem, but with uv's characteristic speed. For GitHub Actions workflows, this means you can run linters, formatters, security scanners, and other tools without managing separate virtual environments or polluting your requirements.txt.
The Caching Challenge
Here's where naive uvx usage hits a wall: every time your GitHub Actions workflow runs, uvx needs to download those tools again. If you're running uvx ruff check on every pull request, you're re-downloading ruff dozens or hundreds of times per day. This creates two problems:
- Wasted time: Downloads take 10-30 seconds per tool, compounding across multiple tools and workflow runs
- Unnecessary network traffic: You're hitting PyPI repeatedly for the same packages
The solution lies in understanding how uv manages its cache. Unlike pip, which scatters cached packages across various locations, uv maintains a centralized cache directory. On Linux and macOS, this lives at ~/.cache/uv. On Windows, it's ~/AppData/Local/uv/cache. This predictable location makes it an ideal candidate for GitHub Actions caching.
Simon Willison's Pattern: A Practical Implementation
Simon Willison recently documented a straightforward approach to caching uvx in GitHub Actions that I think represents best-practice thinking for product teams. His pattern is refreshingly simple:
- name: Cache uv
uses: actions/cache@v4
with:
path: ~/.cache/uv
key: uv-${{ runner.os }}-${{ hashFiles('requirements.txt') }}
restore-keys: |
uv-${{ runner.os }}-
Let's break down why this works:
Path selection: By caching ~/.cache/uv, you're persisting the entire uv package cache across workflow runs. This means any package downloaded by uvx (or uv itself) gets reused.
Key strategy: The cache key combines the runner OS and a hash of your requirements file. This ensures:
- Different operating systems get separate caches (Linux vs. macOS have different binary wheels)
- When your project dependencies change, the cache invalidates appropriately
- Multiple projects can share the same runner without cache collisions
Restore keys: The fallback pattern uv-${{ runner.os }}- means even if your exact requirements hash doesn't match, GitHub Actions will restore the most recent cache for your OS. This provides partial cache hits, which is better than starting from scratch.
My Take: This Should Be Your Default Pattern
I think Simon's approach here represents the right level of abstraction for most product teams. It's not overengineered, it doesn't require custom actions or complex orchestration, and it solves the actual problem.
Having built and maintained CI/CD pipelines for multiple AI products, I've seen teams waste weeks optimizing the wrong things—containerizing everything, building custom Docker images, setting up complex layer caching strategies. Meanwhile, a simple 5-line cache configuration would have given them 80% of the benefit.
The beauty of this pattern is that it's incrementally adoptable. You don't need to refactor your entire workflow. You can add this cache block before your first uvx command, and immediately start seeing benefits. On subsequent runs, tools that took 20 seconds to download now resolve instantly from cache.
One nuance I'd add: if you're using uvx to run multiple different tools (linters, formatters, test runners), consider whether your cache key should depend on requirements.txt at all. If your uvx tools are specified elsewhere—perhaps in a separate dev-requirements.txt or in your workflow file itself—you might want a cache key that reflects that:
key: uv-${{ runner.os }}-${{ hashFiles('.github/workflows/*.yml') }}
This invalidates the cache when your workflow changes, ensuring you're always running the tool versions you specified.
Measuring the Impact
Let's talk about what this optimization actually delivers. While I won't fabricate specific numbers, the performance characteristics are predictable:
First run (cold cache): Your workflow runs exactly as it would without caching. uvx downloads packages, executes commands, and the cache gets populated.
Subsequent runs (warm cache): Package downloads are eliminated entirely. What was 20-30 seconds of network I/O becomes milliseconds of disk reads. For a workflow that runs 5 different uvx tools, you're saving 1-2 minutes per run.
Multiply that across dozens of pull requests per day, and you're talking about hours of saved CI time per week. For teams paying for GitHub Actions minutes, this translates directly to cost savings. More importantly, it tightens your feedback loop—developers get test results faster, which means they stay in flow state rather than context-switching while waiting for CI.
Cross-Platform Considerations
If your product needs to support multiple operating systems (and most should), you'll need to account for platform-specific cache paths. Here's a more robust configuration:
- name: Set up uv cache path
id: uv-cache
shell: bash
run: |
if [ "$RUNNER_OS" == "Windows" ]; then
echo "path=$HOME/AppData/Local/uv/cache" >> $GITHUB_OUTPUT
else
echo "path=$HOME/.cache/uv" >> $GITHUB_OUTPUT
fi
- name: Cache uv
uses: actions/cache@v4
with:
path: ${{ steps.uv-cache.outputs.path }}
key: uv-${{ runner.os }}-${{ hashFiles('requirements.txt') }}
restore-keys: |
uv-${{ runner.os }}-
This dynamic path selection ensures your cache works correctly whether you're running on ubuntu-latest, macos-latest, or windows-latest runners.
Advanced Patterns: Multiple Cache Tiers
For larger projects with complex CI needs, you might want multiple cache tiers. Consider this scenario: you have both project dependencies (managed by uv) and ephemeral tools (managed by uvx). You could implement separate caches:
- name: Cache project dependencies
uses: actions/cache@v4
with:
path: .venv
key: venv-${{ runner.os }}-${{ hashFiles('requirements.txt') }}
- name: Cache uv tools
uses: actions/cache@v4
with:
path: ~/.cache/uv
key: uv-tools-${{ runner.os }}-${{ hashFiles('.github/workflows/*.yml') }}
This separation allows your project dependencies and CI tools to evolve independently. If you change a workflow (updating a linter version), it doesn't invalidate your entire project dependency cache.
Common Pitfalls to Avoid
Over-specific cache keys: I've seen teams create cache keys that include every possible variable—commit SHA, branch name, date. This defeats the purpose. Cache keys should be stable enough to provide hits across multiple runs.
Ignoring cache size limits: GitHub Actions has a 10GB cache limit per repository. If you're caching aggressively across multiple workflows and platforms, you can hit this limit. Monitor your cache usage and consider using restore-keys patterns that allow older caches to be evicted naturally.
Not testing cache invalidation: Always verify that your cache invalidates when it should. If you update a tool version in your workflow but your cache key doesn't reflect that, you'll run stale tools. This is particularly insidious because it fails silently—your workflow succeeds, but with the wrong tool version.
Integrating with uv's Broader Ecosystem
This caching pattern becomes even more powerful when combined with uv's other capabilities. If you're using uv pip compile to generate locked requirements, or uv venv to create virtual environments, those operations also benefit from the cached package downloads.
The key insight is that uv maintains a single, unified cache for all its operations. Whether you're running uv pip install, uvx ruff, or uv sync, they all read from and write to the same cache directory. This means a well-configured cache block benefits your entire Python toolchain.
The Product Builder's Perspective
As someone who builds AI products, I think about CI/CD optimization through the lens of iteration speed. Every improvement to your pipeline's performance is a multiplier on your team's velocity. If your developers are waiting 5 minutes for CI when they could be waiting 2 minutes, that's 3 minutes per pull request they could spend on the next feature.
This compounds. Faster feedback loops mean more experimentation. More experimentation means better products. Better products mean competitive advantage.
The uvx caching pattern is one of those rare optimizations that's both high-impact and low-effort. It doesn't require architectural changes, it doesn't introduce new dependencies, and it doesn't create maintenance burden. You add a few lines to your workflow file, and you're done.
Implementation Checklist
If you're ready to implement this in your own projects, here's a practical checklist:
- Identify your uvx usage: Audit your workflows to find where you're using uvx
- Add the cache block: Place it before your first uvx command
- Choose appropriate cache keys: Consider what should invalidate your cache
- Test both paths: Verify behavior with cold and warm caches
- Monitor cache hit rates: GitHub Actions provides cache analytics—use them
- Document the pattern: Make sure your team understands why the cache exists and how to maintain it
Looking Forward
The Python tooling ecosystem is evolving rapidly, and uv is at the forefront of that evolution. As more teams adopt uvx for tool management, I expect we'll see GitHub Actions and other CI platforms provide first-class support for uv caching—perhaps even automatic cache configuration.
Until then, patterns like Simon's give us a pragmatic, proven approach. They bridge the gap between uv's modern architecture and GitHub Actions' caching infrastructure, delivering real performance improvements without complexity.
For product builders, the message is clear: if you're using uvx in your CI/CD pipelines (and you should be), implement caching. The few minutes you spend configuring it will pay dividends in faster builds, lower costs, and happier developers. And in the fast-moving world of AI product development, those advantages matter more than ever.
Frequently Asked Questions
What is the difference between uv and uvx, and when should I use each?
uv is a fast Python package installer and resolver, serving as a drop-in replacement for pip. uvx is a companion tool for running Python applications in isolated, ephemeral environments—similar to npx in the Node.js ecosystem. Use uv when managing your project's dependencies (installing packages into virtual environments), and use uvx when you need to run standalone tools like linters, formatters, or CLI utilities without adding them to your project's dependency tree.
How much GitHub Actions time can I realistically save by implementing uv caching?
The savings depend on how many uvx tools you're running and how frequently your workflows execute. Typically, each uvx tool invocation without caching takes 10-30 seconds for package downloads. With caching, this reduces to milliseconds. For a workflow running 5 different tools across 20 pull requests per day, you could save 15-30 minutes of CI time daily, which compounds to hours per week and translates to both cost savings and faster developer feedback loops.
Will caching the uv directory cause issues if I update tool versions in my workflow?
This depends on your cache key strategy. If your cache key includes a hash of the file where tool versions are specified (like your workflow YAML), the cache will invalidate when you update versions, ensuring you always run the correct tool version. However, if your cache key only depends on project requirements, you might run stale tool versions until the cache naturally expires or is manually cleared. Always design your cache keys to reflect the actual dependencies that should trigger cache invalidation.
Can I use this caching pattern alongside other Python dependency management tools like Poetry or Pipenv?
Yes, absolutely. The uv cache is independent of how you manage your project's core dependencies. You can continue using Poetry, Pipenv, or pip for your application dependencies while using uvx (and caching its downloads) for CI/CD tools like linters and formatters. The uv cache directory only stores packages downloaded by uv and uvx, so it won't interfere with other package managers' caches or virtual environments.