Python pip and Virtual Environments (venv)

Every real Python project depends on third-party packages, and every machine that runs Python ends up hosting many different projects with conflicting version needs. pip is Python’s standard package installer, responsible for downloading and installing packages from the Python Package Index (PyPI). venv is the standard library module that creates lightweight, isolated Python environments so that each project can have its own independent set of installed packages. Together they are the foundation of dependency management in Python, and understanding them well saves you from the single most common source of “it works on my machine” bugs.

Overview / How It Works

pip (“pip installs packages”) is a command-line tool bundled with modern Python installations (3.4+). When you run pip install requests, pip contacts PyPI, resolves the dependency graph for that package (and its dependencies’ dependencies), downloads the appropriate wheel or source distribution for your platform, and unpacks it into a site-packages directory that your Python interpreter searches when you write import requests. By default this site-packages directory is tied to whichever Python interpreter you invoked pip with — which is exactly the problem venv solves.

Without isolation, every package you install goes into one global site-packages directory shared by your entire system. If Project A needs django==3.2 and Project B needs django==5.0, they cannot both be satisfied globally at the same time — installing one overwrites the other. Worse, installing packages into the system Python can silently break OS tools that depend on specific versions of libraries already present.

venv solves this by creating a self-contained directory tree that includes its own site-packages folder, its own python executable (usually a lightweight link or copy of the interpreter that created it), and its own copy of pip. When that environment is activated, your shell’s PATH is temporarily rearranged so that typing python or pip resolves to the versions inside the virtual environment instead of the system-wide ones. Packages installed while the environment is active land in the environment’s private site-packages, completely invisible to any other environment or to the system Python. Deactivating restores your original PATH, and deleting the environment’s folder removes every trace of it — nothing else on your system is touched.

Internally, a venv is just a directory. It contains a pyvenv.cfg file recording which base interpreter created it and its version, a bin/ folder (Scripts/ on Windows) holding the python/pip launchers and the activate script, and a lib/pythonX.Y/site-packages/ folder where your installed packages actually live. Python detects at startup whether it was launched from inside a venv by comparing sys.prefix (the active environment’s root) against sys.base_prefix (the original installation’s root) — if they differ, you are inside a virtual environment.

Syntax

Creating and using a virtual environment follows a consistent pattern across platforms, though the activation command differs.

python -m venv ENV_DIRECTORY_NAME

# Linux / macOS (bash or zsh)
source ENV_DIRECTORY_NAME/bin/activate

# Windows (Command Prompt)
ENV_DIRECTORY_NAME\Scripts\activate.bat

# Windows (PowerShell)
ENV_DIRECTORY_NAME\Scripts\Activate.ps1

# Leaving the environment, on any platform
deactivate
  • python -m venv — runs the venv module as a script; always prefer this over calling a separate venv executable, since it guarantees the environment is built from the interpreter you intended.
  • ENV_DIRECTORY_NAME — any folder name you choose (common conventions: venv, .venv, or env); venv creates this directory if it does not exist.
  • activate — a shell script (not a Python file) that modifies your current shell session’s PATH and prompt; it must be sourced, not executed, on Unix-like shells so the changes affect your current shell rather than a subshell.
  • deactivate — a function the activate script defines; it restores your original PATH and prompt.

Once activated, the everyday pip commands are:

Command Purpose
pip install PACKAGE Install the latest compatible version of a package
pip install PACKAGE==1.2.3 Install an exact, pinned version
pip install -r requirements.txt Install every package listed in a requirements file
pip uninstall PACKAGE Remove an installed package
pip list Show installed packages and their versions
pip freeze > requirements.txt Write exact installed versions to a file, for reproducible installs
pip show PACKAGE Display metadata (version, location, dependencies) for one package
pip install --upgrade PACKAGE Upgrade a package to its newest compatible version

Examples

Example 1: Creating a venv and confirming isolation

The typical workflow starts with creating and activating an environment, then verifying that Python is actually running from inside it.

python -m venv project_env
source project_env/bin/activate
python -m pip install --upgrade pip
python check_env.py
deactivate

Where check_env.py contains ordinary Python code that inspects the interpreter’s own state:

import sys


def in_virtualenv() -> bool:
    return sys.prefix != sys.base_prefix


print(f"sys.prefix: {sys.prefix}")
print(f"sys.base_prefix: {sys.base_prefix}")
print(f"Running inside a virtual environment: {in_virtualenv()}")

Output (paths will differ on your machine, but the pattern is the same):

sys.prefix: /home/user/myproject/project_env
sys.base_prefix: /usr
Running inside a virtual environment: True

When the venv is active, sys.prefix points inside project_env while sys.base_prefix still points at the system Python installation that created it. If you ran the exact same script without activating the environment, both values would be identical and in_virtualenv() would return False. This is precisely how tools like pip and IDEs detect whether you’re “inside” an environment.

Example 2: Parsing a requirements.txt file

Once your environment has packages installed, pip freeze > requirements.txt captures exact versions so teammates (or a production server) can recreate the same environment with pip install -r requirements.txt. Here’s what that pinning actually looks like from Python’s side:

from pathlib import Path

requirements_content = """requests==2.31.0
rich>=13.0,<14
python-dotenv==1.0.1
"""

req_file = Path("requirements.txt")
req_file.write_text(requirements_content)

for line in req_file.read_text().splitlines():
    line = line.strip()
    if not line or line.startswith("#"):
        continue
    if "==" in line:
        name, version = line.split("==")
        print(f"{name} is pinned to exactly {version}")
    else:
        print(f"{line} uses a version range, not pinned")

req_file.unlink()

Output:

requests is pinned to exactly 2.31.0
rich>=13.0,<14 uses a version range, not pinned
python-dotenv is pinned to exactly 1.0.1

The == syntax pins an exact version, which is what pip freeze produces — ideal for reproducible deployments. Range specifiers like >=13.0,<14 are more common in libraries you publish, where you want to allow compatible updates rather than lock consumers to one exact build.

Example 3: Why isolation matters — simulating a version conflict

This example doesn't call pip at all; instead it models, in plain Python, the exact situation venv exists to prevent: two projects that need different versions of the same dependency.

project_a_requirements = {"requests": "2.31.0", "numpy": "1.26.0"}
project_b_requirements = {"requests": "2.19.0", "pandas": "2.2.0"}


def find_conflicts(reqs1: dict[str, str], reqs2: dict[str, str]) -> list[str]:
    conflicts = []
    for package, version in reqs1.items():
        if package in reqs2 and reqs2[package] != version:
            conflicts.append(
                f"{package}: project A wants {version}, project B wants {reqs2[package]}"
            )
    return conflicts


conflicts = find_conflicts(project_a_requirements, project_b_requirements)
for conflict in conflicts:
    print(conflict)

if not conflicts:
    print("No conflicts found")

Output:

requests: project A wants 2.31.0, project B wants 2.19.0

If both projects installed into the same global site-packages, only one version of requests could exist at a time — installing Project B's dependencies would silently break Project A. Giving each project its own venv means each gets its own private site-packages, so both pinned versions can coexist on the same machine without ever interacting.

Under the Hood

When you run python -m venv myenv, Python performs roughly these steps: it creates the directory structure (bin/, lib/pythonX.Y/site-packages/, include/), writes a pyvenv.cfg file recording the path to the base interpreter and whether system site-packages are visible, and places small launcher scripts (or symlinks, on Unix) for python and pip inside bin/. Crucially, it does not copy the entire standard library — the venv's Python still reads the standard library from the original installation, keeping venvs small (a few megabytes) and fast to create.

Activation is pure shell bookkeeping: the activate script prepends the venv's bin/ directory to your PATH environment variable, saves your old PATH and prompt so deactivate can restore them, and (in most shells) changes your prompt to show the environment's name. No Python process is involved in activation itself — it only changes which python and pip your shell finds first the next time you type those commands.

Once inside an active venv, running pip install works exactly as it would systemwide: it resolves and downloads packages from PyPI, but installs them into the venv's own site-packages because that pip binary was built to target that specific interpreter and prefix.

Common Mistakes

Mistake 1: Installing packages globally instead of in a venv. Running pip install pandas without first creating and activating an environment installs the package system-wide, where it can silently clash with other projects or the operating system's own Python tooling. Always create a project-specific environment first:

python -m venv .venv
source .venv/bin/activate
pip install pandas

Mistake 2: Forgetting the environment isn't activated in a new terminal. Activation only affects the shell session it was run in. Opening a new terminal tab or restarting your editor's integrated terminal resets you back to the system Python, and pip install silently goes to the wrong place again. The fix is simply to re-activate in every new shell session before installing or running anything:

source .venv/bin/activate
python check_env.py   # confirm sys.prefix points inside .venv

Mistake 3: Committing the venv folder to version control. A virtual environment contains platform-specific binaries and can be large; committing it bloats your repository and breaks on other operating systems. Instead, add the environment folder to .gitignore and commit only a requirements.txt (or pyproject.toml) so anyone can recreate it with pip install -r requirements.txt.

Best Practices

  • Create one virtual environment per project, and name it consistently (.venv is a widely recognized convention that many editors auto-detect).
  • Add your environment directory to .gitignore; never commit it.
  • Always run python -m pip install --upgrade pip right after creating a new environment — the bundled pip version can lag behind.
  • Pin exact versions with pip freeze > requirements.txt for applications you deploy, so installs are reproducible.
  • Use looser range specifiers (e.g. requests>=2.28,<3) in requirements.txt or pyproject.toml for libraries you publish, to avoid forcing exact versions on downstream users.
  • Check sys.prefix != sys.base_prefix (or simply look at your shell prompt) before running pip install if you're ever unsure whether an environment is active.
  • Delete and recreate an environment rather than trying to "clean" it — environments are cheap and disposable by design.
  • Use python -m pip instead of a bare pip command when you need to be certain which interpreter's pip is running, especially on systems with multiple Python installations.

Practice Exercises

  1. Create a virtual environment named .venv in a new folder, activate it, and write a Python script that prints True if the environment is active and False otherwise, using the sys.prefix/sys.base_prefix comparison shown above.
  2. Inside your activated environment, install any two packages of your choice, then run pip freeze and save the result to requirements.txt. Deactivate, delete the environment folder, recreate it, and use pip install -r requirements.txt to restore the exact same packages.
  3. Write a Python function parse_requirement(line: str) -> tuple[str, str | None] that takes one line from a requirements file (like "django==4.2.1" or "click") and returns a tuple of the package name and its pinned version, or None if the line has no == pin. Test it against a few sample lines.

Summary

  • pip is Python's package installer; it downloads and installs packages (and their dependencies) from PyPI into a site-packages directory.
  • venv creates an isolated directory with its own site-packages, python, and pip, so each project can have independent, non-conflicting dependencies.
  • Activation temporarily changes your shell's PATH so python/pip resolve to the environment's own copies; deactivate restores the previous state.
  • Python detects an active venv by comparing sys.prefix to sys.base_prefix.
  • pip freeze > requirements.txt and pip install -r requirements.txt together give you reproducible environments across machines and teammates.
  • Never commit environment folders to version control; commit only the requirements file.