Developer Productivity

Python Package Management: uv, pip, Poetry, and conda Compared

Python's packaging story has been the language's longest-running complaint, and for most of a decade the honest answer to "which tool should I use?" was "it depends, and you'll be unhappy either way." That's changed enough to be worth a fresh look.

The short version, and the rest of this is the reasoning: use uv for new application and script work, pip plus venv when you need the lowest-common-denominator toolchain that is certain to be there, Poetry when your team is already on it and productive, and conda-family tooling only when you have non-Python binary dependencies that PyPI can't supply. The interesting differences are not speed. They're what gets locked, who owns the environment, and whether the tool manages the Python interpreter itself.

The four jobs a "package manager" is doing

Before comparing tools, separate the jobs, because each tool covers a different subset and that's where the confusion comes from:

  1. Resolve and install dependencies from an index (usually PyPI).
  2. Lock an exact, reproducible set — versions plus hashes — so the machine that builds in CI gets what you tested with.
  3. Manage the environment — create and activate the virtualenv, keep it in sync with the lock.
  4. Manage the interpreter — install and pin the Python version itself.

pip does job 1 and, with help, part of 2. Poetry does 1–3. uv does all four. conda does all four plus non-Python binaries. That's the whole comparison; the rest is detail.

pip + venv: the floor everyone stands on

pip ships with Python. venv is in the standard library. This combination will exist on any machine with Python installed, which is a real and underrated property.

python -m venv .venv
source .venv/bin/activate      # Windows: .venv\Scripts\activate
pip install -r requirements.txt

What it does well. Universality, zero installation, and a proper backtracking resolver since pip 20.3 rather than the old first-match-wins behaviour. Every CI image, every Docker base image, every tutorial assumes it works.

Where it falls short. requirements.txt produced by pip freeze is not a lockfile. It's a snapshot of one machine's environment, it flattens direct and transitive dependencies into one undifferentiated list, and it usually carries no hashes.

The standard fix is pip-tools: keep your direct dependencies in requirements.in, compile them to a pinned requirements.txt.

pip-compile requirements.in --generate-hashes -o requirements.txt
pip-sync requirements.txt

That gives you real hash-pinned reproducibility (pip install --require-hashes refuses anything unpinned), at the cost of one output file per platform and Python version you support. pip-tools resolves for the machine it runs on.

Pick it when: you need maximum compatibility, you're working inside an existing pip-based repo, or the environment forbids installing extra tooling.

uv: the current default recommendation for new work

uv is from Astral, the team behind the Ruff linter, and it's written in Rust. It's noticeably faster than the alternatives — a Rust implementation, a global module cache that hardlinks into environments instead of re-extracting, and parallel downloads — but benchmark it on your own project rather than trusting anyone's multiplier, including mine. Speed is the headline and the least interesting part. The interesting parts:

It manages the interpreter. uv python install 3.12 fetches and manages a Python build, collapsing pyenv (or asdf) out of your toolchain entirely.

It has two front doors. A pip-compatible interface, uv pip install, that works as a near drop-in on existing projects — so you can adopt it in a legacy repo on day one without touching the project layout — and a project interface built on pyproject.toml plus a uv.lock:

uv init myapp && cd myapp
uv add fastapi 'uvicorn[standard]'
uv add --dev pytest ruff
uv run pytest              # creates/syncs the venv implicitly
uv sync --frozen           # CI: install exactly the lock, fail if stale

The lockfile is cross-platform. uv.lock records a resolution valid across platforms and Python versions in your declared range, rather than one output per environment. If you ship to Linux from macOS laptops, that difference does real work.

uv run handles PEP 723 script metadata. A single-file script can declare its own dependencies inline and be executed directly:

# requires-python = ">=3.11"
# dependencies = ["httpx", "rich"]
# ///
import httpx, rich
uv run fetch.py

No virtualenv, no requirements file, no README instructions. For internal tooling and one-off scripts this is the best quality-of-life improvement in modern Python. Relatedly, uvx runs a tool in a throwaway environment (uvx ruff check .), covering pipx's job.

Trade-offs, stated plainly. It's the youngest tool here and moves quickly, so pin the version you use in CI. It's a single-vendor project — open-source, but concentrated. And it's another binary to install where you don't control the image.

Pick it when: you're starting anything new, you want the interpreter managed too, or your CI installs are slow enough to be annoying.

Poetry: cohesive, opinionated, still solid

Poetry got there first on the thing that mattered — a real lockfile and a coherent project model, at a time when pip offered neither.

poetry new mylib && cd mylib
poetry add requests
poetry add --group dev pytest
poetry install --sync
poetry build && poetry publish

What it does well. poetry.lock is a genuine lockfile with hashes. Dependency groups are clean. It handles environments for you. And it's a competent build/publish tool for libraries — poetry build and poetry publish are a complete path to PyPI, backed by the poetry-core build backend.

What to know. Poetry historically used its own [tool.poetry] table rather than the standardised [project] table; PEP 621 support landed in Poetry 2.0, so which style a repo uses tells you roughly how old its setup is. Resolution can be slow on large dependency graphs — the specific pain point that made uv land so hard. And its default caret version constraints are stricter than many maintainers expect, occasionally producing conflicts a looser constraint set wouldn't hit.

Pick it when: your team already uses it and is productive, or you're publishing libraries and value the integrated build-and-publish path.

conda, mamba, and pixi: a different problem

The conda family isn't really competing with the others. It's a general binary package manager that happens to handle Python, and its reason to exist is dependencies that aren't Python at all: compiled numerics linked against MKL, CUDA toolchains, GDAL, R interop. mamba reimplemented conda's resolver for speed; pixi is a newer project-oriented take on the same ecosystem with a lockfile-first model.

Pick it when: your dependency tree includes system-level binaries that PyPI wheels don't cover. Otherwise the ecosystem tax — separate channels, separate environments, mixing conda and pip in one env — isn't worth paying.

Choosing, in one table

Situation Reach for Because
New application or service uv Lock, env, and interpreter in one tool; fast CI
One-off script or internal tool uv run with PEP 723 Dependencies declared in the file; nothing to set up
Publishing a library Poetry, or uv + hatchling Both give a clean build/publish path
Existing pip repo, low appetite for change pip-tools Real hash-pinned locks, no workflow change
Locked-down or minimal environment pip + venv Present by default
CUDA / compiled scientific stack conda-forge, mamba, or pixi Non-Python binaries
Team already happy on Poetry Poetry Migration cost rarely beats a working setup

That last row is the one people skip. Switching dependency tooling is a whole-team change with a long tail of broken local setups and CI edge cases — the same class of decision as the toolchain audit in auditing a small team's tool sprawl. Do it because your current tool costs you time, not because something newer exists.

Two things to get right whatever you pick

Commit the lockfile, and install from it in CI. The whole value proposition collapses if CI re-resolves. Use the strict mode your tool offers — uv sync --frozen, poetry install --sync, pip install --require-hashes — so a stale lock fails the build instead of silently drifting.

Order your Dockerfile for cache hits. Copy the manifest and lock, install, then copy source. Otherwise every source edit reinstalls the world:

COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY . .

In a multi-package repo, whether each package gets its own lock or the workspace shares one is a structural decision with the same trade-offs as monorepo vs polyrepo generally — uv workspaces and Poetry both have opinions here, and consistency matters more than which you choose.

FAQ

Is uv a drop-in replacement for pip?

uv pip is close to a drop-in for the common commands and is designed for exactly that migration, but it isn't byte-for-byte identical — some rarely used flags and behaviours differ. Treat it as a compatible interface you should smoke-test, not an exact alias.

Do I still need virtualenv or pyenv with uv?

No. uv creates and manages environments itself, and uv python install manages interpreter versions. That's two tools out of a typical setup, which is a meaningful part of its appeal beyond raw speed.

Is requirements.txt a lockfile?

Not as usually produced. pip freeze output is a flat snapshot of one environment, without hashes and without distinguishing direct from transitive dependencies. Compiling it with pip-compile --generate-hashes from a requirements.in turns it into something that genuinely functions as one.

Should an existing Poetry project migrate to uv?

Only if resolution or CI install time is genuinely hurting you. Poetry's lockfile is real and its workflow is sound. Migration means touching pyproject.toml, regenerating locks, updating CI, and re-onboarding everyone's local setup — that's a real cost against a benefit measured in seconds per build.


Concept-level comparisons like this one are for the decision. When you're mid-task and need the exact command — creating a venv, pinning a transitive dependency, fixing a resolution error — the task-level Python snippets live in the reference at TheAppCode.

Comments are disabled for this article.