Skip to content

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-packages folder 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.
ToolYearStatus in 2026
pip + venvancient + still worksThe lowest common denominator. No lockfile.
pip-tools2016Adds lockfile via pip-compile. Still useful, slow.
Poetry2018Popular for libraries; slower than uv; opinionated.
Pipenv2017Largely abandoned for new projects.
Conda / mamba2012Necessary for non-Python deps (CUDA, MKL, R).
uv2024Rust-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 pip from 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 install outside a venv. The single most-cited beginner mistake. Always be in a venv first (or use uv add, which handles it automatically).
  • Mixing pip and uv installs in one venv. Mostly fine, but the lockfile only tracks uv operations. Stick to one tool per project.
  • pip freeze > requirements.txt captures 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.txt from pip-tools).
  • Wrong Python version. uv reads .python-version and will install / use that version. Set it explicitly for the project.
  • CUDA wheel installation. pip install torch may install a CPU-only wheel. PyTorch’s site has the right --index-url flag 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.
  • uv is 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 with pyproject.toml.
  • Docker images layer-cache by copying pyproject.toml + lockfile first, running uv sync, then copying source — reuses the heavy install layer across iterations.
  • uv tool install ruff installs 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