Python virtual environments & pip, explained
Install enough packages globally and two projects will eventually demand conflicting
versions of the same library. Virtual environments solve this by giving each project its own
isolated set of dependencies. Combined with pip and a pinned requirements file, they make
installs reproducible across machines.
The problem: global installs collide
There's one system Python with one site-packages. Install django==3 for project A and
django==5 for project B globally and one of them breaks — you can't have both at once.
pip install django==3 # project A needs this
pip install django==5 # ...now project A is broken
Virtual environments give each project its own private site-packages, so versions never
collide.
Creating and activating a venv
The standard library's venv module creates an isolated environment — a folder with its own
Python and package directory. Activating it points python and pip at that folder.
python -m venv .venv # create the environment
source .venv/bin/activate # activate (Linux/macOS)
# .venv\Scripts\activate # Windows
python -m pip install requests # installs into .venv only
deactivate # leave the environment
Once activated, pip install affects only this project. The .venv folder is disposable and
should be git-ignored — you recreate it from your requirements file.
How pip installs packages
pip downloads packages from PyPI (the Python Package Index) and installs them, along
with their dependencies, into the active environment.
pip install requests # latest compatible version
pip install "django>=4,<5" # a version range
pip install -e . # editable install of the local project
pip list # what's installed
pip show requests # details + dependencies
Always run it as python -m pip when in doubt — it guarantees you're using the pip tied to
the Python you mean.
Pinning with requirements.txt
To reproduce an environment elsewhere, freeze your installed versions to a file and install from it. This is what makes a build deterministic.
pip freeze > requirements.txt # snapshot exact versions
# elsewhere:
pip install -r requirements.txt
A requirements.txt of pinned versions (requests==2.31.0) ensures every machine and CI run
gets identical dependencies — no "works on my machine" surprises.
pyproject.toml and dependency groups
Modern projects declare dependencies in pyproject.toml instead of (or alongside)
requirements files. It's the standard for packaging and for specifying what your project
needs.
[project]
name = "myapp"
dependencies = ["requests>=2.31", "rich"]
[project.optional-dependencies]
dev = ["pytest", "ruff"]
pip install . installs the runtime deps; pip install ".[dev]" adds the dev extras. This
keeps everything about the project in one declarative file.
Faster, all-in-one tooling
Newer tools wrap environment + dependency management together. uv (and Poetry, pipenv) create the venv, resolve and lock dependencies, and install — much faster than plain pip and with reproducible lockfiles.
uv venv # create environment
uv pip install requests # install into it
uv lock # write an exact lockfile
For new projects these are worth adopting; for understanding the fundamentals, venv + pip
is exactly what they automate.
Recap
A virtual environment gives each project its own isolated site-packages so dependency
versions never collide — create one with python -m venv .venv, activate it, and install
freely. pip fetches packages (and their dependencies) from PyPI into the active
environment. Make installs reproducible by pinning versions (pip freeze →
requirements.txt, then pip install -r), or declare them in pyproject.toml. Modern
tools like uv and Poetry bundle environment creation, locking, and installing into
one fast workflow.