Using uv in GitHub Actions for Faster Python CI
> Originally published on DevToolHub.
Switching a GitHub Actions workflow from pip to uv means swapping pip install -r requirements.txt for uv sync --locked, adding one official action, and turning on a cache flag. The payoff isn't the "10-100x faster" number uv's own docs advertise for cold installs — in a real CI job with a warm cache, the honest number is closer to 1.4x. Still worth it, but worth knowing which number is real before you promise your team a 10x pipeline.
What setup-uv Actually Installs
The official astral-sh/setup-uv action downloads the uv binary, puts it on PATH, and — if you ask it to — manages a cache. It does not manage your Python interpreter by default; that's a separate step. Pin it to a commit SHA, not a floating version tag, the same way you'd pin any third-party action that runs with repo access:
- name: Install uv
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
As of this writing v10.3.0 (released 2026-10-09) is the latest tag. Check the releases page before you copy this into a workflow — this action ships new versions often, and GitHub Actions security best practices already cover why floating tags on third-party actions are a supply-chain risk, not just a staleness problem.
Installing Python: Two Valid Paths
You have two options, and they're not interchangeable.
Let uv manage Python:
- name: Set up Python
run: uv python install
This respects a .python-version file or a requires-python pin in pyproject.toml, and it's the simpler option if you don't need anything else from actions/setup-python.
Use the official setup-python action instead:
- uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
The uv docs themselves note this path gets faster caching in some cases. If your workflow already depends on setup-python outputs elsewhere, there's no strong reason to rip it out just to use uv's own installer.
For matrix builds across Python versions, pass the version through setup-uv directly instead of juggling two actions:
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
python-version: ${{ matrix.python-version }}
Caching: What You Get for Free, What You Still Configure
enable-cache: true on setup-uv persists uv's package cache across runs using GitHub's own Actions Cache, keyed against your lockfile. That's the whole setup for most projects — this replaces the cache: pip line you'd have had on actions/setup-python before.
⚠️ Important: on self-hosted runners, that cache directory grows unbounded across runs since there's no ephemeral VM to reset it. The docs recommend pointing UV_CACHE_DIR inside your workspace and running uv cache prune --ci as a cleanup step if you're not on GitHub-hosted runners.
Running the Actual Job
- name: Install the project
run: uv sync --locked --all-extras --dev
- name: Run tests
run: uv run pytest tests
uv sync --locked fails the build instead of silently re-resolving if uv.lock is out of sync with pyproject.toml — that's the flag you want in CI, where a surprise dependency bump should break the pipeline loudly, not pass quietly with different versions than what's pinned.
The Real Numbers, Not the Marketing Ones
A real migration writeup from the CPython blurb tool (PR python/blurb#32) gives honest before/after timing instead of a synthetic benchmark:
| Job | No cache | With cache |
|---|---|---|
| Linting (pre-commit) | 60s → 37s (1.62x) | 11s → 11s (no change) |
| Testing (tox) | 2m 0s → 1m 26s (1.40x) | 1m 58s → 1m 22s (1.44x) |
That's the number to plan around — 1.4-1.6x on a real job, not the 10-100x cold-install figure from uv's own benchmark page.
What Breaks When You Migrate from tox + pip
If your test runner is tox, migrating isn't just swapping the install command. The real gotcha: uv environments don't have pip installed by default. Any tox.ini line that shells out to pip install -r requirements.txt breaks. Two fixes:
- Replace pip-based installs with tox's own deps = -r requirements.txt declaration, which tox-uv understands natively.
- Or add uv_seed = True to the relevant tox.ini environment to restore pip, setuptools, and wheel.
Also remove any leftover cache: pip line from a setup-python step you're keeping — uv handles its own caching.
Frequently Asked Questions
Q: Does uv replace actions/setup-python entirely?
A: Not necessarily. uv python install can manage the interpreter on its own, but actions/setup-python still works alongside uv.
Q: Why pin setup-uv to a commit SHA instead of a version tag?
A: A tag can be moved to point at different code later, a SHA can't. This matters for third-party actions like setup-uv.
Q: Does uv sync --locked fail the build if the lockfile is stale?
A: Yes — that's why you use --locked in CI instead of plain uv sync, which would silently re-resolve.
Q: Is the 10-100x speed claim fake?
A: No, it's real for a cold install with no cache. Most CI pipelines with caching enabled will see 1.4-1.6x instead.
Quick Summary:
- astral-sh/setup-uv, SHA-pinned, with enable-cache: true is the whole caching setup for most projects
- uv sync --locked fails loudly on a stale lockfile — use it in CI, not plain uv sync
- Real CI speedup: 1.4-1.6x, not the 10-100x cold-install marketing number
- uv environments don't ship pip — tox.ini pip lines need uv_seed = True or a deps = rewrite
- Self-hosted runners need manual cache pruning since there's no ephemeral VM reset
Mentions
uvの--lockedと--frozenを検証する:CIの同期成功は何を保証するか uv sync --frozenが成功しても、pyproject.tomlへ追加した依存が入っているとは限りません。 小さなプロジェクトでlockfileの更新漏れを作ったところ、--lockedはエラーで止まり、--frozenは成功しました。ただし後者では、新しく追加したidnaをimportできる状態になっていませんでした。 この記事では、この差を再現し、CI(変更のたびにテストなどを実行する仕組み)で何を検証したいかから指定を選びます。実測環境はLinux、uv 0.11.19、Python 3.12.3です。GitHub Actions自体は実行していません。 lockfileが一致していない状態を作る 既存プロジェクトを壊さないよう、空の検証用ディレクトリで行います。uvと、次の条件を満たすPythonが使えることが前提です。 最初のpyproject.tomlは次の内容です。ビルド設定を持たない小さな検証用プロジェクトで、アプリの配布は行いません。 [project] name = "uv-ci-example" version = "0.1.0" requires-python = ">=3.12,<3.13" dependencies = ["packaging>=24,<26"] [dependency-groups] dev = ["pytest>=8,<9"] 比較対象のPython系統をそろえてから、最初のlockを作る uv python pin 3.12 uv lock uv sync --locked ここではpyproject.tomlとuv.lockが整合しています。lockfileは、解決済みの依存関係を記録したファイルです。pyproject.tomlが求める条件と、実際に使う解決結果を別々に扱えるようになります。uvのプロジェクト構成 続いて、pyproject.tomlのdependencies行だけを次へ変更します。 lock更新漏れを再現するため、この段階ではuv addを使わない dependencies = ["packaging>=24,<26", "idna==3.10"] この行は既存の[project]内にある行を置き換えるものです。別の場所へ追記しないでください。通常の依存追加にはuv addを使えますが、今回は意図的にuv.lockを更新しない状態を作っています。uvの依存関係管理 --lockedでは更新漏れを検出できた 設定とlockの不整合を検出できるか確認する uv sync --locked 実測では終了コード1になり、lockfileの更新が必要なのに--lockedが指定されている、というエラーになりました。uv.lockの内容は変更されていません。 これは想定した失敗です。CIに「コミットされた依存設定を、そのlockfileで扱えるか」を検証させたいなら、この段階で止まることに意味があります。 --frozenは成功したが、追加依存は入らなかった 同じ状態のまま、今度は次を実行しました。 不整合な設定を保ったまま、既存lockを使った場合を比較する uv sync --frozen 通常のuv runによる自動更新を避け、未導入の状態を調べる uv run --frozen python -c "import importlib.util; assert importlib.util.find_spec('idna') is None" 両コマンドとも終了コード0でした。2つ目のコマンドが成功したことは、この環境でidnaが見つからなかったことを意味します。uv.lockも変更されていません。 確認時にuv runへ--frozenを付けたのは、観測中にlockや環境が自動更新されるのを避けるためです。通常のuv runには、コマンドの起動前に必要なlockと同期を行う動作があります。uvのlockと同期 2つの指定は、確認する条件が違う 図:公式仕様と本記事の検証を基に独自作成。GitHub上の実行画面ではありません。 操作 今回の不整合への動作 成功から読み取れること uv sync --locked 更新が必要なので停止 成功した場合、uvの判定でlockが現在の設定と整合している uv sync --frozen 整合性確認を省き、古いlockで同期 選ばれたlockと同期対象のグループで環境を作れた。現在の設定との整合性は別途確認が必要 uv lockの後にuv sync --locked lockを更新してから同期 更新した解決結果を使って環境を作れる --frozenは「より厳しい固定」という意味ではなく、lockfileの最新性確認を省く指定です。仕様はLocking and syncingに記載されています。 また、--lockedもファイルの文字列が完全一致するかを検査するものではありません。たとえば依存の条件を変えても、既存lockが引き続き条件を満たす場合があります。新しいパッケージ版が公開されたことだけで、lockが不整合になるわけでもありません。 このため、通常のCIでは--lockedを出発点にし、別工程で整合性を保証しているなどの理由がある場合に--frozenを選ぶ、という整理ができます。 更新する工程と、検証する工程を分ける 先ほどの検証用プロジェクトでは、次の操作で不整合を解消できます。 依存変更をlockへ反映し、差分をレビューできる状態にする uv lock uv sync --locked 更新後には追加した依存が見つかることを確認する uv run --locked python -c "import importlib.util; assert importlib.util.find_spec('idna') is not None" CIへ渡すときは、pyproject.tomlとuv.lockの両方をGitで管理します。通常のテスト実行中にlockを作り直すより、依存を変更する作業で更新し、その差分を確認してからCIへ渡す方が、何をテストしたかを追いやすくなります。 pytestをdevグループに登録したプロジェクトなら、uvとPythonの準備後のテスト部分は次のようになります。これはjobs.<job_id>.steps内の断片です。 - name: Sync dependencies CI内の暗黙のlock更新で、更新漏れが隠れるのを防ぐ run: uv sync --locked - name: Run tests テスト起動時にも同じ整合性条件を保つ run: uv run --locked pytest -q uv runにも--lockedを付けることで、実行直前の自動更新に頼らず検証します。tests/にはプロジェクトのテストを用意してください。上の依存設定と同期だけでは、テストケースは生成されません。uvのGitHub Actions連携 lockfileだけで実行環境全体が固定されるわけではない 図:公式仕様と本記事の検証を基に独自作成。GitHub上の実行画面ではありません。 ローカル検証では、pytest 8.4.2とpackaging 25.0を導入し、バージョンの順序を比較するテスト3件を実行しました。検証用の.venvを削除して再同期した後も、同じ3件が成功しました。依存追加後にlockを更新した後も成功しています。 ただし、これはGitHubのrunner(ジョブを実行するマシン)でも同じ挙動になることをすべて確認した結果ではありません。 対象 そろえる方法 注意点 依存関係 uv.lockを共有し、--lockedで同期 OSやPython条件により導入対象が変わり得る uv本体 setup-uvのversion入力などで指定 Actionの版だけではuv本体まで固定されない Python CIと手元で同じ版を選択 3.12指定ではパッチ版まで一致するとは限らない OS 対象runnerを選ぶ 同じOSラベルでもイメージ内部は更新される Pythonの選択とuv本体の導入方法は、uvのPythonバージョン管理とsetup-uvの入力設定を参照してください。 キャッシュヒットでもsyncは必要 キャッシュは取得・ビルド済みの成果物を再利用する仕組みです。今回必要なパッケージを決めるlockfileや、その内容から環境を作る同期処理の代わりにはなりません。uvのキャッシュ キャッシュが復元できてもuv sync --lockedは実行し、今回の環境を整えます。逆にキャッシュがない初回でも、必要なパッケージを取得できればテストまで進める構成にします。 私は、CIでの再現性は「固定するファイルを増やす」だけでなく、どの不整合をエラーにするかを決めることが大事だと考えています。今回の例では、--frozenで同期できたことより、--lockedが更新漏れを検出したことの方が、依存変更をレビューする目的に合っていました。 公式資料の確認日は2026年9月11日です。ローカルでの同期・不整合検出・テストを確認し、GitHub Actionsのイベント発火、Actionの実行、キャッシュ復元と速度は未検証として区別しています。 uvのプロジェクト管理へ移るところから確認したい場合は、uvとpip・venvの違いと移行手順も参照してください。 GitHub Actionsへ導入するためのworkflow全体と、Python・uvの固定、キャッシュ、サブディレクトリの設定は、uvをGitHub Actionsで使う方法にまとめています。
Using uv in GitHub Actions for Faster Python CI > Originally published on DevToolHub. Switching a GitHub Actions workflow from pip to uv means swapping pip install -r requirements.txt for uv sync --locked, adding one official action, and turning on a cache flag. The payoff isn't the "10-100x faster" number uv's own docs advertise for cold installs — in a real CI job with a warm cache, the honest number is closer to 1.4x. Still worth it, but worth knowing which number is real before you promise your team a 10x pipeline. What setup-uv Actually Installs The official astral-sh/setup-uv action downloads the uv binary, puts it on PATH, and — if you ask it to — manages a cache. It does not manage your Python interpreter by default; that's a separate step. Pin it to a commit SHA, not a floating version tag, the same way you'd pin any third-party action that runs with repo access: - name: Install uv uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true As of this writing v10.3.0 (released 2026-10-09) is the latest tag. Check the releases page before you copy this into a workflow — this action ships new versions often, and GitHub Actions security best practices already cover why floating tags on third-party actions are a supply-chain risk, not just a staleness problem. Installing Python: Two Valid Paths You have two options, and they're not interchangeable. Let uv manage Python: - name: Set up Python run: uv python install This respects a .python-version file or a requires-python pin in pyproject.toml, and it's the simpler option if you don't need anything else from actions/setup-python. Use the official setup-python action instead: - uses: actions/setup-python@v5 with: python-version-file: ".python-version" The uv docs themselves note this path gets faster caching in some cases. If your workflow already depends on setup-python outputs elsewhere, there's no strong reason to rip it out just to use uv's own installer. For matrix builds across Python versions, pass the version through setup-uv directly instead of juggling two actions: strategy: matrix: python-version: ["3.11", "3.12", "3.13"] steps: - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: python-version: ${{ matrix.python-version }} Caching: What You Get for Free, What You Still Configure enable-cache: true on setup-uv persists uv's package cache across runs using GitHub's own Actions Cache, keyed against your lockfile. That's the whole setup for most projects — this replaces the cache: pip line you'd have had on actions/setup-python before. ⚠️ Important: on self-hosted runners, that cache directory grows unbounded across runs since there's no ephemeral VM to reset it. The docs recommend pointing UV_CACHE_DIR inside your workspace and running uv cache prune --ci as a cleanup step if you're not on GitHub-hosted runners. Running the Actual Job - name: Install the project run: uv sync --locked --all-extras --dev - name: Run tests run: uv run pytest tests uv sync --locked fails the build instead of silently re-resolving if uv.lock is out of sync with pyproject.toml — that's the flag you want in CI, where a surprise dependency bump should break the pipeline loudly, not pass quietly with different versions than what's pinned. The Real Numbers, Not the Marketing Ones A real migration writeup from the CPython blurb tool (PR python/blurb#32) gives honest before/after timing instead of a synthetic benchmark: | Job | No cache | With cache | |---|---|---| | Linting (pre-commit) | 60s → 37s (1.62x) | 11s → 11s (no change) | | Testing (tox) | 2m 0s → 1m 26s (1.40x) | 1m 58s → 1m 22s (1.44x) | That's the number to plan around — 1.4-1.6x on a real job, not the 10-100x cold-install figure from uv's own benchmark page. What Breaks When You Migrate from tox + pip If your test runner is tox, migrating isn't just swapping the install command. The real gotcha: uv environments don't have pip installed by default. Any tox.ini line that shells out to pip install -r requirements.txt breaks. Two fixes: - Replace pip-based installs with tox's own deps = -r requirements.txt declaration, which tox-uv understands natively. - Or add uv_seed = True to the relevant tox.ini environment to restore pip, setuptools, and wheel. Also remove any leftover cache: pip line from a setup-python step you're keeping — uv handles its own caching. Frequently Asked Questions Q: Does uv replace actions/setup-python entirely? A: Not necessarily. uv python install can manage the interpreter on its own, but actions/setup-python still works alongside uv. Q: Why pin setup-uv to a commit SHA instead of a version tag? A: A tag can be moved to point at different code later, a SHA can't. This matters for third-party actions like setup-uv. Q: Does uv sync --locked fail the build if the lockfile is stale? A: Yes — that's why you use --locked in CI instead of plain uv sync, which would silently re-resolve. Q: Is the 10-100x speed claim fake? A: No, it's real for a cold install with no cache. Most CI pipelines with caching enabled will see 1.4-1.6x instead. Quick Summary: - astral-sh/setup-uv, SHA-pinned, with enable-cache: true is the whole caching setup for most projects - uv sync --locked fails loudly on a stale lockfile — use it in CI, not plain uv sync - Real CI speedup: 1.4-1.6x, not the 10-100x cold-install marketing number - uv environments don't ship pip — tox.ini pip lines need uv_seed = True or a deps = rewrite - Self-hosted runners need manual cache pruning since there's no ephemeral VM reset
Astral’s setup-uv 10.2.0 stops automatically saving caches for GitHub merge queues. The Sep 21 release says those caches would almost never be reused. It also updates known checksums through uv 0.12.17. github.com/astral-sh/setup-uv/releases/tag/v10.2.0 #Astral #SetupUV #GitHubActions