Whetstone.
The 2026 Toolchainuv, the whole thing
Module 2 · Lesson 218 min

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

Zero to running
uv init my-agent
cd my-agent
uv add langchain httpx
uv add --dev pytest ruff pyright
uv run python -m my_agent

uv 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:

pyproject.toml, after uv add httpx and uv add --dev pytest ruff
[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

Everything goes through it
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 lock resolves pyproject.toml into uv.lock. Does not touch your environment.
  • uv sync makes .venv match uv.lock exactly, installing what is missing and removing what should not be there. That last part is why it is stronger than pip 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

More than just dev
[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:

Extras are published. Groups are not.
[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, for when you are somewhere else
uv pip install requests
uv pip freeze
uv pip compile requirements.in -o requirements.txt

uv 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.

Practice

Try it yourself

Quiz

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?

  1. AIt creates an empty environment and runs the command in it, leaving dependency installation to an explicit uv sync
  2. BIt fails, because no .venv exists yet and uv requires uv sync to have been run at least once
  3. CIt runs pytest from your system Python, since with no environment present uv falls back to the ambient interpreter
  4. Duv checks the lockfile against pyproject.toml, syncs the environment to the lockfile, then runs the command
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.

Quiz

Where a dev dependency lands

You run uv add --dev pytest in a fresh project. Which part of pyproject.toml gains an entry?

  1. A[dependency-groups], under a group named dev, which is the PEP 735 standard and is installed by default
  2. B[project.dependencies], alongside runtime dependencies, since the split is enforced at install time by a flag
  3. C[project.optional-dependencies], under an extra named dev, which is how development extras are declared
  4. D[tool.uv.dev-dependencies], a uv-specific table that other tools ignore by design
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.

Quiz

Which files are committed

You are setting up .gitignore for a uv project. Which of these belongs in version control?

  1. A.venv and uv.lock are committed; .python-version is a local override and is ignored
  2. Buv.lock and .python-version are committed; .venv is ignored
  3. Cuv.lock only; .python-version pins a machine-local interpreter and would break other contributors
  4. DNone of them, since all three are regenerable from pyproject.toml on any machine
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.

Recall

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.

Check

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 should see

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.

Sign in to track your progress →