Python Packaging — pip, Poetry, uv
How Python projects manage dependencies. The landscape moved fast — pip + requirements.txt, then Poetry/PDM, now uv. Knowing the trade-offs and why the field evolved is the senior signal.
The tools
| Tool | What it is |
|---|---|
| pip | the built-in installer. Installs packages; does not, by itself, manage a project, lock dependencies, or resolve cleanly. |
| pip + pip-tools | pip plus pip-compile to generate a locked requirements.txt from a loose input. The “minimal” modern setup. |
| Poetry | full project manager: dependency declaration, resolution, locking, virtualenv management, building, publishing. pyproject.toml-native. |
| PDM | similar scope to Poetry, standards-forward (PEP 582/621). |
| uv | Astral’s Rust-based tool: a pip/pip-tools/virtualenv/Poetry replacement that is dramatically faster. The current momentum is here. |
| conda | separate ecosystem; manages non-Python deps too (C libs, CUDA). Common in data science, rare in web backends. |
What “pip alone” doesn’t do — and why the others exist
pip install requests works. The problems at a project/team scale:
- No lockfile.
pip freeze > requirements.txtcaptures what’s installed, but it’s flat — it doesn’t distinguish your direct deps from their transitive deps, and it’s not generated from a clean resolution. - No separation of “what I want” vs “what got resolved.” You want “Django >= 5.2”; you need to record “Django 5.2.6 + its exact 30 transitive deps.” Plain pip conflates these.
- No dev/prod groups. pytest and mypy shouldn’t ship to production; plain
requirements.txthas no notion of groups. - Resolution was historically weak — older pip would happily install an inconsistent set; modern pip has a real resolver but still no lockfile.
So the ecosystem built tools that add: a declared dependency spec (loose constraints), a locked resolution (exact pinned graph), dependency groups, and integrated virtualenv handling.
pip + pip-tools — the minimal modern setup
requirements.in # what you declare: "django>=5.2", "celery"
│ pip-compile
▼
requirements.txt # the lock: every package + transitive, pinned, hashed
│ pip-sync
▼
the virtualenv # exactly the locked set, nothing more
pip-compile resolves and pins; pip-sync makes the environment match the lock exactly (removes things not in it). Lightweight, uses only pip under the hood, no new project model. A reasonable choice for teams that want minimal tooling.
Poetry — the integrated project manager
# pyproject.toml
[tool.poetry.dependencies]
python = "^3.12"
django = "^4.2"
celery = "^5.3"
[tool.poetry.group.dev.dependencies]
pytest = "^8.0"
mypy = "^1.11"
poetry add django— declares it, resolves, updatespoetry.lock.poetry install— creates/uses a virtualenv, installs the locked set.poetry.lock— the full pinned, hashed dependency graph; commit it.- Handles build + publish too (
poetry build,poetry publish).
One tool for declare/resolve/lock/venv/build. Poetry’s own resolver was historically slow on large graphs (a real complaint) — which is part of why uv gained ground.
uv — the current momentum
uv (Astral, the ruff people) is a Rust-based tool that replaces pip, pip-tools, virtualenv, and much of Poetry — and is 10-100× faster at install and resolution.
uv venv # create a virtualenv (instant)
uv pip install django # pip-compatible install, much faster
# or the project workflow:
uv add django # declare + resolve + lock (uv.lock)
uv sync # make the venv match the lock
uv run pytest # run a command in the project env
- Drop-in
uv pipcommands for the pip-compatible path. - A full project workflow (
uv add/uv sync/uv.lock) for the Poetry-style path. - Also manages Python versions themselves (
uv python install 3.12) — subsumes pyenv. - The speed isn’t a gimmick: it changes CI times and local iteration meaningfully.
For new projects in 2025+, uv is the increasingly common default. The interview-relevant point is why: the field consolidated toward one fast tool that does everything (versions, venvs, resolution, locking, running), after a decade of stitching pip + virtualenv + pyenv + pip-tools/Poetry together.
What they all share — the underlying model
Regardless of tool, the model is the same and worth stating clearly:
- Declare loose constraints (
django>=5.2) inpyproject.toml(orrequirements.in). - Resolve the full graph to a consistent set of exact versions.
- Lock that resolution to a file (
poetry.lock,uv.lock, pinnedrequirements.txt) — commit it. - Sync an environment to match the lock exactly.
The tool is an implementation detail; the model — declared spec → resolved lock → reproducible environment — is the thing. See 02_virtualenvs_and_lockfiles.md and 03_pyproject_and_building.md.
Choosing in practice
| Situation | Reasonable choice |
|---|---|
| New project, 2025+ | uv — fast, does everything, growing standard |
| Existing Poetry project | stay on Poetry; migrate to uv when convenient, not urgently |
| Minimal tooling, pip-only shop | pip + pip-tools |
| Data science with non-Python deps | conda / mamba (different ecosystem) |
| Library you publish to PyPI | uv or Poetry (both build + publish); see the building file |
Don’t churn a working setup for fashion. But for greenfield, uv is the easy recommendation.
Common gotchas
pip freezeas a lockfile — it’s a flat snapshot of what’s installed, not a resolution; doesn’t separate direct from transitive, isn’t reproducible. Use a real lock.- Not committing the lockfile — then “it works on my machine” is back; the lock is the reproducibility.
pip installstraight into a global/system Python — pollutes it, version conflicts across projects. Always a per-project virtualenv.- Mixing tools —
pip installinto a Poetry-managed venv silently desyncs it frompoetry.lock. Pick one tool per project and use it consistently. - conda + pip mixed carelessly — each can clobber the other’s view of the environment. If you use conda, install via conda where possible.
- Slow Poetry resolution blamed on “Python packaging” — often just the resolver; uv is the answer if speed is the pain.
Interview angle
- “Why isn’t
pip+requirements.txtenough?” — plain pip has no lockfile distinct from a flat install snapshot, no separation of declared vs resolved deps, no dev/prod groups.pip freezecaptures what’s installed, not a clean reproducible resolution. The ecosystem built pip-tools / Poetry / uv to add declared specs, real locks, and groups. - “pip vs Poetry vs uv?” — pip: the installer, no project management. Poetry: integrated declare/resolve/lock/venv/build, but historically slow resolution. uv: Rust-based, 10-100× faster, replaces pip + virtualenv + pyenv + much of Poetry, and is the current default for new projects. The model underneath is identical — declare, resolve, lock, sync.
- “What does a packaging tool actually do for you?” — turns loose declared constraints (
django>=5.2) into a resolved, exact, locked dependency graph, manages the per-project virtualenv, and syncs the environment to match the lock — giving reproducible installs across machines and CI. - “What would you pick for a new service?” — uv: it’s fast (real CI/iteration impact), does versions + venvs + resolution + locking + running in one tool, and the ecosystem is consolidating on it. For an existing Poetry project I wouldn’t churn it just for fashion.
- “Why did Python packaging have so many tools?” — historically you stitched together pip (install) + virtualenv (isolation) + pyenv (versions) + pip-tools/Poetry (locking) because no single tool did it all well. uv consolidating those is the field finally converging.