Python, from the ground up Lesson 17 / 60

Dependency management: pip, poetry, uv — picking yours

The four real options, the trade-offs, and why most new projects in 2026 start with uv.

Every Python project eventually answers two questions about its dependencies. The first is what it needs — a list of names like httpx, pydantic, polars. The second is which exact versionshttpx==0.28.1, pydantic==2.10.4, polars==1.18.0. The first list is short and human-readable. The second list is much longer because it includes every transitive dependency, and it has to be reproducible: every developer, every CI run, every production deploy needs the same versions, or you get the famous “works on my machine.”

The thing that solves the second question is a lock file. Different tools call it different things — requirements.txt, poetry.lock, uv.lock — but the job is identical: pin every package and every sub-package to a single, resolved version, with checksums, so the install is bit-for-bit reproducible.

This lesson is the four real options for managing all of that in 2026, and how to pick one.

What “dependency management” actually means

A modern Python project has, at minimum:

  1. A list of direct dependencies — what your code imports. This lives in pyproject.toml (modern) or requirements.in (older pip-tools workflow).
  2. A lock file — every package in the resolved tree, exact version, hashes. This is the input to a reproducible install.
  3. A way to install those exact versions into a virtual environment.
  4. A way to update them when you want fresh versions.
  5. A way to audit them for known security issues.

The tools differ in how they handle each step, how fast they are, and how much they get in your way.

Option 1: pip + requirements.txt (the classic)

# Direct deps you maintain by hand
echo "httpx" > requirements.txt
echo "pydantic>=2,<3" >> requirements.txt

pip install -r requirements.txt
pip freeze > requirements.txt   # now it has resolved versions

This is what every Python tutorial shows. It works. It has one big problem: pip by itself does not separate the direct dependencies you care about from the transitive ones. After pip freeze your file has both, glued together, and you can’t tell which is which. Bumping one direct dependency means rebuilding the whole list by hand.

It also has no concept of hashes (without --require-hashes), no separation of dev versus prod dependencies, and no upgrade story beyond pip install -U.

Still fine for a one-file script. Not fine for a project anyone else will touch.

Option 2: pip + pip-tools (the disciplined classic)

pip-tools adds the missing layer on top of plain pip. You maintain a requirements.in of direct deps and let pip-compile resolve and lock everything.

pip install pip-tools

# requirements.in — what you actually want
cat > requirements.in <<EOF
httpx
pydantic>=2,<3
EOF

# requirements.txt — generated, locked, with hashes
pip-compile --generate-hashes requirements.in

# Install the locked set
pip-sync requirements.txt

The output requirements.txt is fully resolved, comment-annotated (“via httpx”), and hashed. To upgrade:

pip-compile --upgrade requirements.in            # everything
pip-compile --upgrade-package httpx requirements.in   # one package

Dev dependencies live in a separate file:

pip-compile requirements.in
pip-compile dev-requirements.in   # references requirements.txt as a constraint

If you have an older codebase that lives on pure pip, this is the cheapest upgrade path. No new packaging concepts, no new file formats. Just two files where there used to be one.

Option 3: poetry (the opinionated bundler)

Poetry was the first widely adopted tool to put everything — virtualenv, dependency declaration, lock file, build backend, publish — behind one CLI.

poetry init                # interactive pyproject.toml
poetry add httpx
poetry add --group dev pytest ruff
poetry install             # creates venv, installs from poetry.lock
poetry update              # bumps within your declared ranges
poetry run pytest          # run inside the env

The pyproject.toml it produces has its own [tool.poetry] section (Poetry was around before PEP 621 standardized [project], and it shows). The lock file is poetry.lock. Dev/test/lint deps go into named groups.

What people like about Poetry: one tool does everything, the dev experience is consistent, the lock file is solid. What people don’t: the resolver has historically been slow on big trees, the [tool.poetry] schema diverges from the PEP 621 standard most other tools have moved to, and it’s been slow to adopt newer standards. It’s still a perfectly fine choice, especially on existing projects already using it.

Option 4: uv (the new default)

We met uv in lesson 15. For dependency management it covers the same ground as pip-tools and Poetry combined, but in a Rust binary that resolves and installs an order of magnitude faster.

uv init my-project
cd my-project

uv add httpx pydantic
uv add --dev pytest ruff mypy

uv sync                # install from uv.lock
uv lock --upgrade      # refresh everything
uv lock --upgrade-package httpx
uv run pytest          # run inside the project env

A few details that make uv the recommended choice for new projects in 2026:

  • It uses standard PEP 621 [project] metadata. Your pyproject.toml is portable to any other PEP 621 tool.
  • The lock file (uv.lock) is universal: one file resolves for all platforms and Python versions you support, not one file per OS.
  • Dev dependencies go into [tool.uv.dev-dependencies] or, more portably, [project.optional-dependencies] groups.
  • It manages the Python interpreter itself if you want it to (uv python install 3.13).

If you’re starting a project today and have no organizational reason to pick something else, start with uv.

A note on conda

conda (and the lighter mamba/pixi) is its own ecosystem. It manages Python and non-Python binaries — CUDA, MKL, GDAL, R — through its own package channels. If you live in scientific Python and need a specific BLAS or a CUDA toolkit pinned alongside your numpy, conda solves problems pip can’t. For a typical web/data/automation project it’s overkill. We’ll mention it and move on.

The lock-file question

Every modern tool produces some form of lock file. You should treat it as a first-class source file:

  • Application? Always commit the lock file. The whole point is that your deploy installs the same versions as your laptop.
  • Library? Don’t commit the lock file’s hashes into your published package — your users have their own resolver. But it’s still useful in the repo for development and CI. The convention now is to commit the lock and let publishing strip what doesn’t belong in the wheel.

The lock file is also where you check what changed between deploys. Diffing two uv.lock versions tells you exactly which packages moved and by how much. Code review the lock the same way you review a migration.

Dev dependencies vs production dependencies

Production dependencies are what your application imports at runtime. Dev dependencies are pytest, ruff, mypy, mkdocs, anything that exists only to help you write the code. Don’t ship them.

In modern pyproject.toml:

[project]
name = "my-app"
dependencies = [
  "httpx>=0.28,<0.29",
  "pydantic>=2,<3",
]

[project.optional-dependencies]
test = ["pytest>=8", "pytest-asyncio"]
lint = ["ruff>=0.7", "mypy>=1.13"]

[tool.uv]
dev-dependencies = ["pytest>=8", "ruff>=0.7", "mypy>=1.13"]

pip install my-app[test] pulls in the optional test group. uv sync installs the dev-dependencies automatically; uv sync --no-dev doesn’t.

Version specifiers: pinning vs ranges

You’ll see a lot of these:

httpx                # any version
httpx>=0.28          # at least 0.28
httpx>=0.28,<0.29    # 0.28.x only
httpx~=0.28.1        # >=0.28.1, <0.29 (compatible release)
httpx==0.28.1        # exactly this

Two rules of thumb that will save you from a lot of pain:

  1. For libraries, declare ranges, not pins. If your library pins httpx==0.28.1 and another library pins httpx==0.28.2, neither can be installed in the same environment. Use >=0.28,<0.29 and let the application resolve the exact version.
  2. For applications, the lock file is your pin. You declare ranges in pyproject.toml (so updates are easy) and the lock file pins the exact resolved version (so deploys are reproducible). Don’t manually pin == in pyproject.toml for an app — let the lock do that job.

Updating dependencies

A reasonable cadence is once every couple of weeks for small projects, or whenever a CVE drops:

uv lock --upgrade                      # bump everything within ranges
uv lock --upgrade-package httpx        # bump just one
poetry update
pip-compile --upgrade requirements.in

Run your tests. Diff the lock file. Commit. Done.

When a major version drops and your range excludes it (<0.29), bump the range deliberately, read the changelog, and run the tests again. That’s the moment to find out if anything broke.

Security audits

Lock files are also what security scanners read. The two most common tools:

pip install pip-audit
pip-audit                               # scans the active env
pip-audit -r requirements.txt           # scans a lock file

pip install safety
safety check

GitHub’s Dependabot and uv-aware tools like Renovate will open pull requests automatically when a CVE matches one of your locked versions. Wire one of them up on day one of any project that handles user data. The cost is zero, the cost of not doing it is a 2 a.m. incident.

Picking yours

A short flowchart:

  • New project, no constraints? uv.
  • Existing project on Poetry, no pain? Stay on Poetry.
  • Existing project on plain pip, ready for a small upgrade? pip-tools.
  • Scientific stack with CUDA/MKL/GDAL? conda or pixi.

The rest of this module assumes uv, but the concepts — direct deps, lock file, dev split, ranges, audits — are identical in any of them. Pick a tool, learn its three commands, and move on.

Search