Virtual Envs — pip, uv, poetry
The modern Python packaging landscape and which tool to actually use in 2026.
TL;DR
Don’t pip install into the system Python. Ever. One project, one
isolated environment, one set of pinned dependencies. The tool to use
in 2026 is uv — it’s an order of magnitude faster than pip and
does environment management, lockfiles, and Python version installation
all in one binary.
The mental model:
- A virtual environment is a directory containing a Python
executable + a
site-packagesfolder for installed libraries. When activated, that Python and those libraries are what runs. - A lockfile records exact versions of every direct and transitive dependency, so the install is reproducible.
- A package manager installs packages into the venv and (ideally) maintains the lockfile.
| Tool | Year | Status in 2026 |
|---|---|---|
pip + venv | ancient + still works | The lowest common denominator. No lockfile. |
pip-tools | 2016 | Adds lockfile via pip-compile. Still useful, slow. |
Poetry | 2018 | Popular for libraries; slower than uv; opinionated. |
Pipenv | 2017 | Largely abandoned for new projects. |
Conda / mamba | 2012 | Necessary for non-Python deps (CUDA, MKL, R). |
uv | 2024 | Rust-based, ~10–100× faster, drop-in pip replacement, manages venvs and Python versions. Default for new projects. |
If you have an existing Poetry project, stay on Poetry — uv and
Poetry both work. If you’re starting today, use uv.
Why a venv is non-negotiable
System Python belongs to your OS. Tools you didn’t install
(macOS Spotlight metadata, Linux package managers, your IDE plugins)
depend on specific versions of specific libraries. pip install requests into system Python can break those.
Worse: project A needs numpy<2, project B needs numpy>=2. Without
isolation, switching between them is a manual reinstall every time.
A venv is just a directory. Activate it; the right Python and the right libraries are on PATH. Deactivate; back to system. One project, one venv, no cross-contamination.
Quickstart with uv
# Install uv (one time)
curl -LsSf https://astral.sh/uv/install.sh | sh
# In a new project
cd my-ml-project
uv init # creates pyproject.toml + .python-version
uv venv # creates .venv/
uv add numpy pandas torch # adds to pyproject.toml AND installs
uv add --dev pytest mypy ruff # dev-only deps
# Run code in the project's environment
uv run python train.py
uv run pytest
# When somebody clones your repo
uv sync # installs exactly what's in the lockfile
Everything happens in .venv/ next to your code. No need to “activate”
manually — uv run handles it. (You can still source .venv/bin/activate
if you prefer the old workflow.)
What’s in a pyproject.toml
The modern Python config file. One file replaces setup.py,
setup.cfg, requirements.txt, Pipfile, sometimes tox.ini.
[project]
name = "my-ml-project"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"numpy>=2.0",
"pandas>=2.2",
"torch>=2.5",
"pydantic>=2.0",
]
[dependency-groups]
dev = [
"pytest>=8",
"mypy>=1.10",
"ruff>=0.5",
]
[tool.uv]
# uv-specific settings, e.g. extra index URLs
[tool.ruff]
line-length = 100
uv add foo updates this file and the lockfile (uv.lock) in one
step. The lockfile is what makes builds reproducible — exact versions
of every transitive dep, plus content hashes.
Both files (pyproject.toml and uv.lock) belong in git. The .venv/
does not.
When you need a non-Python dep — Conda
Some ML stacks include components Python’s package manager doesn’t manage well: CUDA toolkit, cuDNN, OpenBLAS, R, system-level libraries. Conda manages these via its own ecosystem.
Use Conda (or its faster fork, mamba / micromamba) when:
- You need a specific CUDA version that isn’t bundled with your PyTorch wheel.
- You need R, Julia, or other languages alongside Python.
- You’re on a platform where pip wheels for a critical dep don’t exist (rare in 2026).
- You’re working with HPC systems that already standardise on Conda.
For pure-Python ML projects (most of them now), uv is enough.
Pinning strategies
Three levels of strictness for the same project:
# Loose — works for libraries you publish
dependencies = [
"numpy>=2.0",
"pandas>=2.2",
]
# Compatible release — common for applications
dependencies = [
"numpy~=2.1.0", # allows 2.1.x but not 2.2
"pandas~=2.2.0",
]
# Exact pin — combined with the lockfile, fully reproducible
dependencies = [
"numpy==2.1.3",
"pandas==2.2.3",
]
For applications and ML services, rely on the lockfile for
reproducibility. The version specifiers in pyproject.toml express
the range you support; the lockfile pins the exact resolved set.
You don’t need both to be tight.
For libraries you publish to PyPI, prefer loose ranges so consumers can pick versions compatible with their other deps.
A typical ML project layout
my-ml-project/
├── .python-version # python 3.11.7
├── pyproject.toml # project metadata + deps
├── uv.lock # locked transitive deps
├── .venv/ # local venv, gitignored
├── README.md
├── src/
│ └── my_project/
│ ├── __init__.py
│ ├── train.py
│ └── ...
├── tests/
│ └── test_train.py
└── notebooks/
└── eda.ipynb
Importable as import my_project.train. Tests live alongside in
tests/. Notebooks go in their own dir.
To make import my_project.train work without setup gymnastics, have
the src/my_project/ layout and either install the project as
editable (uv pip install -e .) or rely on uv run which handles
this automatically.
Performance — why uv is so fast
A few reasons to be aware of, in case you wonder:
- Written in Rust; pip is Python.
- Aggressive parallelism: download many packages concurrently.
- Better caching: identical wheels across projects are stored once.
- Smarter resolver: solves the dependency SAT in seconds for cases where pip takes minutes.
- No interpreter startup per command (compared to
pipfrom the venv).
The practical result: uv sync on a 50-dep ML project runs in 5
seconds, where pip install -r requirements.txt would take 30–60.
For Docker builds and CI, this compounds.
Common gotchas
pip installoutside a venv. The single most-cited beginner mistake. Always be in a venv first (or useuv add, which handles it automatically).- Mixing pip and uv installs in one venv. Mostly fine, but the
lockfile only tracks
uvoperations. Stick to one tool per project. pip freeze > requirements.txtcaptures the current venv state, including transitive deps with no source mark. It’s not a reproducibility primitive — it’s a snapshot. Use a real lockfile (uv.lock,poetry.lock,requirements.txtfrompip-tools).- Wrong Python version.
uvreads.python-versionand will install / use that version. Set it explicitly for the project. - CUDA wheel installation.
pip install torchmay install a CPU-only wheel. PyTorch’s site has the right--index-urlflag for the CUDA build you want. With uv:uv add torch --index-url https://download.pytorch.org/whl/cu121. - Editable installs missing files.
uv pip install -e .doesn’t pick up files added after the install. Re-run after adding new packages or moving things around.
When you should use Poetry instead
- Existing Poetry codebase. Don’t switch mid-project for no reason.
- Publishing libraries to PyPI. Both work; Poetry’s publishing UX is marginally smoother.
- Strong opinions on its workflow already in your team’s muscle memory.
For everything else, uv.
Where this shows up in real ML codebases
- Every modern ML repo has
pyproject.toml+ lockfile. uvis rapidly becoming the default for new repos in 2025–2026. Replit, Hugging Face, Modal, Pydantic, FastAPI, and most prominent Python projects have switched.- CI builds use
uv sync --frozen— install exactly what’s in the lockfile, fail if it’s out of sync withpyproject.toml. - Docker images layer-cache by copying
pyproject.toml+ lockfile first, runninguv sync, then copying source — reuses the heavy install layer across iterations. uv tool install ruffinstalls a tool globally without polluting your venvs (replacement for pipx).
The defensive habit: every new project starts with uv init and
uv venv. Every clone of a project starts with uv sync. Nothing
lives in system Python.
Resources
uvdocumentation — github.com/astral-sh/uv — install instructions, full CLI reference.- Poetry documentation — python-poetry.org — for existing Poetry projects.
- pip-tools — github.com/jazzband/pip-tools — lockfile-only workflow on top of pip.
- PEP 517 / 518 — build system specs — peps.python.org — the standards
pyproject.tomlis built on. - Conda / Miniforge — github.com/conda-forge/miniforge — for projects with non-Python deps.
- Astral blog — astral.sh/blog — design notes on uv and ruff.