Python dependency management has multiple solutions. The right choice depends on what you need.
Why virtual environments
Without isolation:
pip install requests==2.20
# project A works
pip install requests==2.31 # for project B
# project A now broken
Different projects need different versions. Isolate each project in its own environment:
project-a/.venv/ # requests 2.20
project-b/.venv/ # requests 2.31
Standard Python provides venv:
python -m venv .venv
source .venv/bin/activate # macOS/Linux
.venv\Scripts\activate # Windows
pip install -r requirements.txt
This works but is bare-bones.
Tool 1: uv (recommended in 2026)
# Install uv
curl -LsSf https://astral.sh/uv/install.sh | sh
# Create new project
uv init my-project
cd my-project
# Add dependencies
uv add requests pydantic
# Add dev dependencies
uv add --dev pytest pyright ruff
# Install/sync
uv sync # installs everything from lock
uv sync --frozen # CI mode; fail on lock mismatch
# Run code in the env (no activation needed)
uv run python main.py
uv run pytest
# Add to existing
uv pip install requests # uses pip-compatible interface
What you get:
- Single binary; no Python install required to bootstrap.
- 10-100x faster than pip.
- Lockfile (
uv.lock) committed to git. - Manages Python versions too (
uv python install 3.12).
pyproject.toml:
[project]
dependencies = ["requests>=2.31"]
[project.optional-dependencies]
dev = ["pytest>=7"]
uv reads this; produces uv.lock; installs.
In 2026, uv is the default recommendation. Best speed, simplest workflow.
Tool 2: Poetry
# Install poetry
curl -sSL https://install.python-poetry.org | python3 -
# New project
poetry new my-project
cd my-project
# Add deps
poetry add requests
poetry add --dev pytest
# Install
poetry install
# Run
poetry run python main.py
poetry shell # activate poetry's venv
What you get:
- Mature, well-tested.
- Lockfile (
poetry.lock). - Built-in publishing (
poetry publish). - More feature-rich than pip-tools.
Drawbacks vs uv:
- Slower (Python-based; not Rust).
- More opinionated.
Common when codebase started before uv was popular.
Tool 3: pip + pip-tools (simple stack)
# requirements.in
requests
pydantic
# Compile to requirements.txt
pip-compile requirements.in
# Outputs:
# requests==2.31.0 (pinned)
# pydantic==2.5.3
# urllib3==2.0.7 (transitive)
# ...
# Install
pip install -r requirements.txt
What you get:
- Closest to "just pip" — minimal magic.
- Works in any pip-compatible environment.
- Standard requirements.txt output (compatible with everything).
Drawbacks:
- No project metadata (separate pyproject.toml).
- Less integrated than uv/poetry.
Common in: CI environments, Docker images, simple scripts.
Tool 4: conda (data science world)
conda create -n my-env python=3.12
conda activate my-env
conda install numpy pandas
What's different:
- Has its own package repository (Anaconda).
- Manages non-Python deps (CUDA, compilers).
- Different package format.
Use when: PyData/ML stack with system-level dependencies. Otherwise prefer pip ecosystem.
Hybrid: uv + pyproject.toml + requirements.txt for Docker
Common pattern:
- Develop with uv (
uv sync). - For Docker image:
uv export > requirements.txtthenpip install -r requirements.txtin the image. - Production image doesn't need uv.
Best of both: dev speed + slim production image.
Lockfiles — why and when
A lockfile pins exact versions of all dependencies, including transitive ones:
# uv.lock (excerpt)
[[package]]
name = "requests"
version = "2.31.0"
source = "registry+https://pypi.org/simple/"
dependencies = [
{name = "urllib3", version = "2.0.7"},
...
]
Why:
- Reproducibility: same code on different machines = same packages.
- Security: hash verification.
- Debugging: bisect against known-working version.
Commit the lockfile. CI uses --frozen mode to enforce.
Common patterns
CI install
# uv
uv sync --frozen --no-dev # production deps only
uv sync --frozen # all deps for tests
# Poetry
poetry install --no-dev
poetry install
# pip
pip install -r requirements.txt
Docker
FROM python:3.12-slim
# Use uv for fast install
COPY pyproject.toml uv.lock ./
RUN pip install uv && uv sync --frozen --no-dev
# Or simpler: use requirements.txt
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY src ./src
CMD ["python", "-m", "my_package"]
Updating dependencies
uv add requests --upgrade
uv sync
# Test it works
git commit pyproject.toml uv.lock
Or for periodic upgrades:
uv sync --upgrade # upgrade all to latest compatible
Common dependency-management mistakes
- No lockfile. Builds break randomly.
- Multiple requirements.txt files. Hard to keep in sync; use [project.optional-dependencies].
- Pinning too tight without reason. Updates are blocked.
- Pinning too loose. Breaking changes hit silently.
- Mixing pip and conda. Confusing; pick one.
Migration: pip → uv
# If you have requirements.txt:
uv init
uv add $(cat requirements.txt)
# OR: keep requirements.txt; just speed up with uv pip
uv pip install -r requirements.txt
Migration: poetry → uv
# Export poetry deps
poetry export -f requirements.txt > /tmp/deps.txt
# Then init uv and re-add
Or use a tool like migrate-to-uv. Most production teams migrate gradually; new projects start on uv.
Takeaway
Virtual environments: isolate per-project. uv (2026 default): fast, modern, single tool. Poetry: mature, feature-rich, slower. pip + pip-tools: minimal, works everywhere. Conda: PyData ecosystem. Always commit a lockfile. Hybrid uv + requirements.txt for production Docker. New projects → uv; existing → migrate at your own pace.