The traditional Python toolchain is six programs that do not know about each other:
-
pyenvinstalls interpreters, -
virtualenvmakes environments, -
pipinstalls into them, -
pip-toolspins, -
pipxinstalls command-line tools globally, -
toxruns 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.