Skip to content

A Modern Python Workflow with Astral uv

Astral uv logo

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

brew install uv
curl -LsSf https://astral.sh/uv/install.sh | sh
pwsh -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

Create a Project

uv init hello-uv
cd hello-uv

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

uv add httpx
uv run main.py

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:

uv add pydantic
uv run pytest
uv run python -m your_package

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:

uv pip compile requirements.in -o requirements.txt
uv venv
uv pip sync requirements.txt

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:

uv add fastapi
uv run uvicorn app:app --reload

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:

uv init --script cleanup.py --python 3.12

You get a file that starts like this:

# /// script
# requires-python = ">=3.12"
# dependencies = []
# ///

Now add dependencies directly to the script:

uv add --script cleanup.py "boto3>=1.37" "rich>=14.0"
uv lock --script cleanup.py
uv run cleanup.py

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 run and uv sync operate from the workspace root by default,
  • --package lets 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:

uv sync --all-packages
uv run --package api pytest
uv run --package shared python -m shared

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.

uv python install 3.12 3.13
uv python pin 3.12
uv run --python 3.13 pytest

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.lock is 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-uv bootstraps the tool,
  • uv.lock drives cache invalidation,
  • uvx handles tooling without polluting the project,
  • uv sync --frozen guarantees 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

uv pip compile requirements.in -o requirements.txt
uv venv
uv pip sync requirements.txt

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:

uv init --bare
uv add -r requirements.in -c requirements.txt
uv sync

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:

sed '/^-r /d' requirements-dev.in | uv add --dev -r - -c requirements-dev.txt

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:

  1. Keep your pyproject.toml.
  2. Move project metadata to standards-based [project] fields if it still lives only under Poetry-specific tables.
  3. Regenerate the lockfile with uv.
  4. Replace poetry install / poetry run in docs and CI with uv sync / uv run.

In command form, that usually looks like:

uv lock
uv sync
uv run pytest

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 init
  • uv add
  • uv run
  • uv sync
  • uvx
  • uv 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