Skip to content
← All articles

uv as the project workflow

The pyenv + virtualenv + pip + pip-tools + pipx + tox stack has six failure modes and no shared lockfile. One tool with one lockfile removes most of them.

The traditional Python toolchain is six programs that do not know about each other:

  • pyenv installs interpreters,
  • virtualenv makes environments,
  • pip installs into them,
  • pip-tools pins,
  • pipx installs command-line tools globally,
  • tox runs the matrix.

Each is fine. The seams between them are not. There is no shared record of what is installed, so “works on my machine” has six possible causes and no single artefact to diff. The most common failure — the environment does not match what anyone intended — cannot be detected by any of them, because none of them owns the answer.

uv covers all six with one lockfile as the shared truth.

Interpreters

uv python install 3.12 3.13
uv python pin 3.12

pin writes a .python-version file that every subsequent uv run and uv sync respects. Contributors get the right interpreter without a paragraph in the README.

Starting a project

uv init --lib     # a package: src layout, uv_build backend, ready to publish
uv init --app     # an application: src layout, a main entry point
uv init --no-package   # scripts in a directory, no distribution to build
uv init --bare    # just a pyproject.toml, nothing else

--lib and --app both scaffold a src layout — the layout that stops your tests from importing the working tree instead of the installed package. That default is doing more work than it looks like.

Day-to-day

uv add httpx
uv add --dev pytest
uv add --group typing mypy
uv remove httpx
uv lock
uv sync
uv tree
uv run pytest
uv build
uv publish

uv add edits pyproject.toml, updates the lockfile and syncs the environment — three steps that used to be three commands and a chance to forget one. uv run guarantees the environment matches the lockfile before running, which quietly eliminates the “did you re-install after pulling” question.

Tools

uvx ruff check .            # run without installing
uv tool install ruff        # install on PATH, isolated

uvx is the pipx run equivalent and the reason a CI job can lint without an environment at all. Isolation matters here: your linter’s dependencies have no business resolving against your project’s.

uv pip is the escape hatch

uv pip install, uv pip compile, uv pip sync implement the familiar lower-level interface. Useful for a container build, an existing requirements.txt workflow, or anything not project-shaped. It bypasses the project’s lockfile, which is exactly right when there is no project and exactly wrong when there is.

Single-file scripts: PEP 723

uv add --script analyse.py httpx

writes a metadata block into the script itself:

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

uv run analyse.py then builds a throwaway environment with those dependencies and runs it. A one-file tool with real dependencies stops needing a project, a README, or an install step — it is one file you can email.

💡uv run script.py and python script.py behave differently on the same file. Name two ways. click to reveal

The environment. uv run resolves and materialises an environment first: the project’s, or — for a PEP 723 script — a throwaway one built from the inline metadata. python script.py uses whatever interpreter is on PATH with whatever happens to be installed in it. A script that works under uv run and fails under python usually has a dependency that was never installed into the ambient environment.

The interpreter version. uv run honours .python-version and requires-python, downloading a matching interpreter if needed. python is whichever one PATH resolves, which on a developer machine is frequently not the one the project targets.

There is a third worth knowing: uv run syncs the project before running, so a stale environment is repaired silently. That is convenient day to day and is precisely why CI must use --locked rather than relying on the default — the same repair that helps you locally will hide a forgotten lockfile update in a pull request.

The flag that decides whether your pipeline is honest

This is the one people get wrong, and it is repeated here because getting it wrong ships a stale tree:

Command If the lockfile is out of date with pyproject.toml
uv sync re-locks silently, then installs
uv sync --locked fails
uv sync --frozen installs from the lockfile as-is, no comparison

CI uses --locked. With the default, a pull request that adds a dependency and forgets to commit the updated lockfile goes green — CI resolved a fresh lockfile in memory, tested it, and discarded it. Production installs the stale committed one. Two different dependency trees, one green build, no way to tell from the pipeline output.