uv, the whole thing
One tool. It replaces pip, venv, pip-tools, pipx, pyenv, and most of what people used poetry and pipenv for. It is a single Rust binary with no Python bootstrap, which is the reason it can manage Python itself without a chicken-and-egg problem.
The daily surface is six commands and you will have them in an hour.
The map from a toolchain you already know
| pnpm | uv | Note |
|---|---|---|
pnpm init |
uv init |
Also creates a lockfile, a .python-version and a src layout. |
pnpm add x |
uv add x |
Writes to pyproject.toml and updates the lock. |
pnpm add -D x |
uv add --dev x |
Lands in a PEP 735 dependency group. |
pnpm remove x |
uv remove x |
|
pnpm install |
uv sync |
Rarely needed by hand. See below. |
pnpm exec cmd |
uv run cmd |
The one you type most. |
pnpm dlx cmd |
uvx cmd |
Ephemeral tool, no install. |
nvm install 22 |
uv python install 3.14 |
uv manages interpreters too. |
Starting a project
uv init my-agent
cd my-agent
uv add langchain httpx
uv add --dev pytest ruff pyright
uv run python -m my_agentuv init gives you a git repo, a .gitignore, a README.md, a .python-version, a pyproject.toml, and an entry point. The uv.lock and the .venv do not exist yet: they appear on the first command that needs them, which is usually the first uv add.
Here is what pyproject.toml actually contains after those two add commands, copied from a real run rather than reconstructed:
[project]
name = "my-agent"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"httpx>=0.28.1",
]
[dependency-groups]
dev = [
"pytest>=9.1.1",
"ruff>=0.16.1",
]Read that as package.json with the sections renamed. [project] is the metadata plus runtime dependencies, [dependency-groups] is devDependencies, and that really is the whole file. A [build-system] table shows up only when the project is something you intend to build and publish, and you can ignore it entirely until that day arrives.
uv run is the command that changes your habits
uv run python main.py
uv run pytest
uv run ruff check
uv run python -c "import langchain; print(langchain.__version__)"The documented behaviour is the important part: before every uv run invocation, uv verifies that the lockfile is up to date with pyproject.toml, and that the environment is up to date with the lockfile. So a fresh clone plus one uv run pytest creates the environment, installs everything, and runs the tests. No setup step, no activation, no README instructions beyond the command itself.
Locking and syncing
uv lockresolvespyproject.tomlintouv.lock. Does not touch your environment.uv syncmakes.venvmatchuv.lockexactly, installing what is missing and removing what should not be there. That last part is why it is stronger thanpip install -r.
You will rarely type either, because uv run does both when needed. The times you want them explicitly are CI (uv sync --locked fails rather than updating the lock, which is what you want in a pipeline) and after a manual edit to pyproject.toml.
uv.lock is cross-platform: one lockfile covers macOS, Linux and Windows on every supported Python version, resolved as a single universal solution rather than one per platform. Commit it. Commit .python-version too. Ignore .venv.
Dependency groups
[dependency-groups]
dev = [
{include-group = "test"},
{include-group = "lint"},
]
test = ["pytest>=8.4.0", "pytest-asyncio>=1.0.0"]
lint = ["ruff>=0.13.0", "pyright>=1.1.400"]Groups are PEP 735 and they nest. dev is special only in that it is installed by default, which is why uv run pytest works with no flags. --group test, --only-group lint and --no-dev control the rest, and --dev is documented as exactly equivalent to --group dev.
The distinction that actually matters is groups against extras:
[project.optional-dependencies]
postgres = ["asyncpg>=0.30.0"]An extra is part of your package’s public interface: it is what lets a consumer write pip install my-agent[postgres]. A group is private to the repo and never ships. Before PEP 735 there was no group concept, so projects put dev tooling in extras and accidentally published their test dependencies as a feature. If you see a dev extra in an older repo, that is what you are looking at.
Reading a version specifier
Different characters, same ideas, one genuine gap.
| npm | Python | Means |
|---|---|---|
^1.2.3 |
>=1.2.3,<2.0.0 |
Compatible within the major. Written out longhand. |
~1.2.3 |
~=1.2.3 |
Patch-level only. |
1.2.3 |
==1.2.3 |
Exact. A bare version string is not a pin. |
* |
(omit) | Any version. |
uv add httpx writes httpx>=0.28.1, a lower bound only, because the lockfile is what pins the actual version. That looks alarmingly loose next to an npm caret and it is fine for the same reason a caret is fine: the lock is the source of truth, the specifier only describes the acceptable range.
The genuine gap is that Python has no equivalent of node_modules nesting. Every package in an environment must agree on one version of every shared dependency, because there is only one site-packages. npm can install two versions of the same library at different depths; Python cannot, ever. Resolution therefore fails where npm would have quietly succeeded, and the error you get is a wall of text listing which requirement conflicted with which.
The escape hatch
uv pip install requests
uv pip freeze
uv pip compile requirements.in -o requirements.txtuv pip is a drop-in reimplementation of pip’s interface for environments that are not uv projects: a Docker image, a legacy repo, a colleague’s requirements.txt. It is much faster than pip and is deliberately not the project workflow. Reach for it when you are working inside someone else’s arrangement, not when starting your own.
Where this goes
That is projects. The next lesson is the two things uv does that have no pnpm analogue at all: managing the Python interpreter itself, and running a single file that declares its own dependencies inside its own source.
Try it yourself
What uv run does before it runs anything
You clone a colleague's project, do not create an environment, and immediately type uv run pytest. What happens?
Show answer
Correct answer: D — uv checks the lockfile against pyproject.toml, syncs the environment to the lockfile, then runs the command
The docs are explicit that prior to every uv run invocation uv verifies the lockfile is up to date with pyproject.toml and the environment is up to date with the lockfile. Creating and populating the environment is part of that, so a fresh clone plus one command works. This is the single behaviour that makes the activation ritual obsolete, and it is why uv sync is rarely needed by hand. The system-Python-fallback option describes the failure everyone fears from experience with older tooling, which is exactly why it is worth ruling out deliberately.
Where a dev dependency lands
You run uv add --dev pytest in a fresh project. Which part of pyproject.toml gains an entry?
Show answer
Correct answer: A — [dependency-groups], under a group named dev, which is the PEP 735 standard and is installed by default
uv add --dev writes to [dependency-groups] under dev, per PEP 735, and --dev is documented as equivalent to --group dev. The dev group is included by default in uv run and uv sync, which is why tests work with no extra flag. The optional-dependencies option is the pre-PEP-735 workaround and its real problem is that extras are PUBLISHED with the package, so your test dependencies become part of your public interface. [tool.uv.dev-dependencies] genuinely existed and is on its way out; uv still writes there if the table is already present, which is the only reason you will see it in older repos.
Which files are committed
You are setting up .gitignore for a uv project. Which of these belongs in version control?
Show answer
Correct answer: B — uv.lock and .python-version are committed; .venv is ignored
uv.lock is committed for the same reason pnpm-lock.yaml is: it is the reproducibility guarantee, and it is cross-platform, so one lockfile serves every contributor. .python-version is committed because it states the project's Python version, which is a project fact rather than a machine fact, and uv will install that version for anyone who lacks it. .venv is generated, machine-specific, and often contains platform-specific binaries. The nothing-is-committed option is the reasoning that makes lockfiles look redundant, and it is wrong in the same way for uv as for npm: regenerable is not the same as identical.
The command map
Six commands is genuinely the whole daily surface. Write them out from memory.
Map the uv commands you will use every day onto their pnpm equivalents, and name the one that has no equivalent.
Reveal answer
uv init is pnpm init. uv add x is pnpm add x, and uv add --dev x is pnpm add -D x. uv remove x is pnpm remove x. uv sync is pnpm install, though you rarely need it explicitly. uv lock is a lockfile refresh with no install. uv run cmd is pnpm exec cmd and is how you run absolutely everything. uvx tool is pnpm dlx. The one with no equivalent is uv python install 3.14, because uv also manages the interpreter itself, which is closer to what nvm or fnm does for Node than to anything pnpm does.
Build one from scratch
Ten minutes at a terminal. uv init scratch, cd scratch, uv add httpx, uv add --dev pytest, then open pyproject.toml and read every line of it.
You have a uv.lock you did not ask for, httpx under [project] dependencies and pytest under [dependency-groups] dev, and you ran something with uv run without ever typing activate.