A Modern Python Workflow with Astral uv

Python packaging has needed a reset for years, and uv is the first tool in a long time that feels like a real one. It's fast, opinionated in the right places, and broad enough to replace an entire pile of Python tooling with a single binary that actually makes the day-to-day workflow simpler.
The official uv docs lead with the claim that it's 10-100x faster than pip, and that number doesn't feel like marketing fluff once you use it for real work. uv gets there by doing something the older Python toolchain never pulled together cleanly: it treats dependency resolution, environment management, Python installation, and tool execution as one system.
The old Python workflow was fragmented:
| Job | Old Stack | New Stack |
|---|---|---|
| Install Python | pyenv | uv python install |
| Pin Python | .python-version plus pyenv local | uv python pin |
| Create virtualenv | python -m venv or virtualenv | automatic via uv sync / uv run |
| Install deps | pip install | uv add or uv pip install |
| Lock deps | pip-tools, Poetry, or ad hoc freeze files | uv.lock |
| Run tools | pipx, global installs, or hand-managed venvs | uvx |
| Run project commands | python, poetry run, shell glue | uv run |
That fragmentation costs real time. Every layer has its own cache, its own mental model, and its own failure mode. uv replaces that with a global cache, universal lockfile support, automatic environment syncing, and built-in Python management.
The biggest performance story is the cache. uv keeps a global, content-addressed cache of downloaded and built artifacts, then links or clones files from that cache into environments instead of redownloading and reinstalling everything from scratch. Repeat installs get cheaper, branch switches hurt less, and CI caches actually pay off.
If the cache and environment live on the same filesystem, uv can link from the cache into the environment instead of falling back to slower copies. That's why the second install often feels almost instant, and why the official docs stress keeping cache and project environments on the same filesystem when you can.
Think Cargo; Not a Faster pip
Think of uv as "Cargo for Python" more than "a faster pip."
If you approach it as a package installer only, you'll miss most of the value.
Quick Start¶
If you want the shortest path from zero to a working project, this is it.
Install uv¶
Create a Project¶
That gives you a pyproject.toml, a starter main.py, a .python-version, and a Git repository by default.
Add a Dependency, Sync, and Run¶
The key thing to understand is that uv add is not a thin wrapper around pip install. It updates project metadata, refreshes uv.lock, and syncs the environment. uv sync is the explicit "make the environment match the lockfile" command you'll use constantly in CI and after branch changes.
If you want a minimal everyday loop, it looks like this:
The New Default Workflow¶
There are two ways to use uv, and knowing which lane you are in matters.
Compatibility Lane: uv pip¶
If you are migrating gradually and still thinking in terms of requirements.txt, the uv pip interface is the bridge:
This is the lowest-friction entry point for existing teams.
Native Lane: uv add, uv lock, uv sync, uv run¶
If you are starting fresh, skip the compatibility mindset and use the project workflow:
If you want to make the lockfile refresh explicit, uv lock is always available. But for everyday work, uv add and uv run are usually the ergonomic default.
This is the model uv is designed around, and it's the one that removes the most ceremony.
PEP 723: Single-File Scripts That Carry Their Own Dependencies¶
PEP 723 defines inline metadata for single-file Python scripts. uv makes it practical with uv init --script, uv add --script, uv lock --script, and uv run.
Create a script:
You get a file that starts like this:
Now add dependencies directly to the script:
That turns a throwaway script into a reproducible artifact. No requirements.txt, no README note that says "create a venv first", no mystery global packages on somebody's laptop.
For DevOps and platform teams, that shows up in places where setup docs usually rot:
- cron jobs can live as one portable file,
- incident response scripts can be shared without a venv README,
- GitHub Actions helper scripts stop depending on runner state,
- internal automation becomes easier to review because the dependency list is in the file.
Here is the kind of script layout I would actually ship:
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "boto3>=1.37",
# "rich>=14.0",
# ]
# ///
from rich.console import Console
console = Console()
console.print("Rotating credentials...", style="bold green")
That's a Python script with its runtime contract embedded in the file itself. For operational automation, that's the right abstraction level.
PEP 723 Scripts Inside a Project
If you run uv run from inside a project, project dependencies are normally included. PEP 723 script metadata is the exception: uv will use the script's declared dependencies instead.
Workspaces: Monorepo Support Without Poetry Gymnastics¶
uv supports Cargo-style workspaces, and that matters if you have a Python monorepo with multiple packages that need to move together.
The core model is simple:
- each member has its own
pyproject.toml, - the workspace shares one lockfile,
uv runanduv syncoperate from the workspace root by default,--packagelets you target a specific member.
A minimal root pyproject.toml can look like this:
[project]
name = "platform"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["shared"]
[tool.uv.sources]
shared = { workspace = true }
[tool.uv.workspace]
members = ["packages/api", "packages/shared"]
And your workflow becomes predictable:
This is a much cleaner story than bolting together separate virtualenvs and hand-managed path dependencies. The trade-off is equally important: workspaces assume a shared dependency universe. If packages truly need conflicting environments, keep them as separate projects and use path dependencies instead.
uvx: Tool Isolation Without Dependency Hell¶
Every Python team accumulates CLI tools: ruff, mypy, cookiecutter, mkdocs, awscli, yamllint, and on and on. The usual outcome is ugly:
- some tools are globally installed,
- some live in one random virtualenv,
- some are pinned in project dev dependencies,
- nobody is completely sure which version is actually running.
uvx fixes that by running tools in isolated environments on demand. It's the pipx use case, integrated into the rest of the uv model.
Examples:
uvx ruff check .
uvx mypy src
uvx --from cookiecutter cookiecutter gh:audreyfeldroy/cookiecutter-pypackage
This isn't only about convenience. It keeps tool dependencies from leaking into your project environment. Application deps stay about the application. CLI tools stay isolated. That boundary is healthy.
For CI, it's even better. If all you need is linting, uvx ruff check . is often cleaner than adding Ruff to the project dependency graph at all. If you're standardizing on Astral tooling, see Python Code Quality: Black, Flake8, and Ruff for how Ruff fits next to uv in day-to-day work.
For automation, pin the tool version explicitly. uvx is great for isolation, but unpinned CI tooling is still floating CI tooling.
Python Management: Stop Depending on System Python¶
People talk about uv as a faster pip and overlook that it can install and pin Python interpreters too.
That means you don't have to rely on:
- whatever Python your operating system happens to ship,
- a separately installed
pyenv, - a hand-maintained shell setup that breaks on a new machine.
uv will prefer managed interpreters, and you can lean into that model hard if you want a fully self-contained workflow. This is especially useful for teams standardizing onboarding or for CI pipelines where interpreter drift causes intermittent failures.
GitHub Actions¶
This is where a lot of teams get tripped up at first: uv is not installed on GitHub's default hosted runners.
That's not a problem, but it does mean you should be explicit.
Use astral-sh/setup-uv¶
The official action is astral-sh/setup-uv. It installs uv, adds it to PATH, and can persist the uv cache between workflow runs.
If you care about supply-chain hygiene, pin actions to commit hashes. I covered the reasoning in Why You Should Pin GitHub Actions to Commit Hashes.
Cache the Right Thing¶
Don't cache .venv first. Cache uv's global package cache, keyed primarily from uv.lock.
That's the right strategy because:
uv.lockis the authoritative dependency state,- the cache survives environment recreation,
- warm caches make matrix builds much cheaper,
- you avoid stale virtualenv problems.
The official action already supports this with enable-cache and cache-dependency-glob.
Example Workflow¶
---
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
python-version: ${{ matrix.python-version }}
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
cache-python: true
- name: Lint
run: uvx --from "ruff==0.11.13" ruff check .
- name: Sync
run: uv sync --frozen
- name: Test
run: uv run --frozen pytest
- name: Prune cache for CI
run: uv cache prune --ci
This pattern scales well because each step has one job:
setup-uvbootstraps the tool,uv.lockdrives cache invalidation,uvxhandles tooling without polluting the project,uv sync --frozenguarantees the job uses the committed lockfile,- the matrix proves you are not only testing one Python version by accident.
Freeze the Lockfile in CI
If your repository already has uv.lock, uv sync --frozen and uv run --frozen ... should be the default in CI. Let pull requests update the lockfile explicitly, not implicitly during tests.
Transition Path¶
The easiest way to adopt uv is to stop treating it as one command and start treating it as a toolbox with two modes: uv pip for compatibility, native uv project commands for everything better.
Cheat Sheet¶
Use these tables when you're translating muscle memory from another tool. Open the section that matches what you're replacing.
pip, pip-tools, and Virtual Environments
| If you use... | Then use this... | When |
|---|---|---|
pip install requests | uv add requests | Project workflow: metadata plus lockfile updates |
pip install requests | uv pip install requests | Imperative, pip-style behavior |
pip install -r requirements.txt | uv pip sync requirements.txt | You already have a locked requirements file |
pip-compile requirements.in -o requirements.txt | uv pip compile requirements.in -o requirements.txt | Staying in requirements-file mode |
python -m venv .venv | uv venv .venv | You need an explicit virtualenv |
pip uninstall requests | uv remove requests | Remove a project dependency cleanly |
pip freeze > requirements.txt | uv export -o requirements.txt | Export for another system; add --no-dev or --format requirements.txt when needed |
pipdeptree | uv tree | Inspect the dependency graph |
Poetry and Native Project Commands
| If you use... | Then use this... | When |
|---|---|---|
poetry add fastapi | uv add fastapi | Native uv project workflow |
poetry add --group dev pytest | uv add --dev pytest | Dev-only dependency group |
poetry lock | uv lock | Refresh the lockfile |
poetry install | uv sync | Reconcile the environment with the lockfile |
poetry run pytest | uv run pytest | Run commands inside the project environment |
pipx and uvx
| If you use... | Then use this... | When |
|---|---|---|
pipx run ruff | uvx ruff check . | Run a tool in an isolated environment |
pipx run --spec "ruff==0.11.13" ruff check . | uvx --from "ruff==0.11.13" ruff check . | Pin a one-off tool version, especially in CI |
pyenv and Python Versions
| If you use... | Then use this... | When |
|---|---|---|
pyenv install 3.12 | uv python install 3.12 | Install Python itself |
pyenv versions | uv python list | See which versions uv manages |
python --version or which python | uv python find --show-version | Check which interpreter uv will use |
pyenv local 3.12 | uv python pin 3.12 | Pin a project interpreter |
pyenv uninstall 3.12 | uv python uninstall 3.12 | Remove a managed Python version |
PEP 723 Single-file Scripts
| If you use... | Then use this... | When |
|---|---|---|
hand-written script plus requirements.txt | uv init --script cleanup.py --python 3.12 | Bootstrap inline script metadata |
| edit script requirements by hand | uv add --script cleanup.py rich | Add dependencies to a PEP 723 script |
pip-compile for a standalone script | uv lock --script cleanup.py | Lock script dependencies explicitly |
python cleanup.py with a prebuilt venv | uv run cleanup.py | Run a script with managed dependencies |
CI Lockfile Discipline
| If you use... | Then use this... | When |
|---|---|---|
poetry install --sync | uv sync --frozen | Force CI to use the committed lockfile |
poetry run pytest in CI | uv run --frozen pytest | Run tests without mutating the environment |
Migrating from requirements.txt¶
If you want the lowest-risk migration from a pip or pip-tools project, do it in two phases.
Phase 1: Keep the Requirements Workflow and Speed it Up¶
That gives you immediate speed wins without changing the team's mental model.
Phase 2: Adopt Native uv Projects¶
The official migration guide recommends importing the source requirements file and preserving pinned versions as constraints:
That converts direct dependencies into pyproject.toml and carries locked versions into uv.lock.
Development requirements¶
If requirements-dev.in includes -r requirements.in, strip that line before importing so your base dependencies don't get duplicated into the dev group:
If your old workflow had platform-specific lockfiles, normalize them first with uv pip compile --no-strip-markers, then import them as constraints so uv can generate one universal lockfile.
Migrating from Poetry¶
Why keep two project managers in the loop? Pick one. If you are moving to uv, let uv own the lockfile.
The practical migration path is:
- Keep your
pyproject.toml. - Move project metadata to standards-based
[project]fields if it still lives only under Poetry-specific tables. - Regenerate the lockfile with
uv. - Replace
poetry install/poetry runin docs and CI withuv sync/uv run.
In command form, that usually looks like:
If you want the safest bridge from an older Poetry setup, export or reconstruct your direct dependencies, then re-add them with uv add so the new lockfile is authored by uv, not translated forever from another tool's model.
Recommendations¶
If you're starting a new Python project in 2026, default to:
uv inituv adduv runuv syncuvxuv python install
Keep uv pip as a migration bridge, not the end state. The shift isn't "faster pip." It's one binary for installs, locks, interpreters, tools, and CI, with a cache that makes the second run cheap.
References¶
- Official Documentation:
docs.astral.sh/uv - GitHub Action:
astral-sh/setup-uv - Video Walkthrough: uv on YouTube