PythonMastery
intermediate 20 min read · lesson 9 of 12 in Python How-To

Virtual Environments

1 · The lesson

read

Every Python project should live inside its own virtual environment. No exceptions. The moment you pip install something globally is the moment you start collecting compatibility problems that take weeks to untangle.

The problem is concrete — Project A needs requests==2.31. Project B is an older codebase that still depends on requests==2.20. Both can't coexist in your system Python. A virtual environment (venv) is a self-contained Python installation that lives inside a project folder, with its own python, its own pip, and its own site-packages. Each project gets one. They never talk to each other.

This lesson covers venv, the modern uv replacement, pipx for CLI tools, lockfiles, and the conventions that prevent your laptop turning into a dependency landfill.


1. venv — The Standard Library Tool

Every Python 3 install ships with venv. No third-party tool required.

bash
python -m venv .venv

That creates a .venv/ directory in the current folder containing a Python binary, a fresh pip, and an empty site-packages. The convention is to name the directory .venv (leading dot, hidden in ls) so it's easy to gitignore and easy to spot.


2. Activate, Use, Deactivate

The activation script is different per shell.

macOS / Linux:

bash
source .venv/bin/activate

Windows PowerShell:

powershell
.venv\Scripts\Activate.ps1

Windows cmd:

cmd
.venv\Scripts\activate.bat

Your prompt changes to show the active venv — typically (.venv) $. Now python and pip point at the venv's copies, not the system ones:

bash
which python                        # /path/to/project/.venv/bin/python   (macOS/Linux)
where python                        # ...\project\.venv\Scripts\python.exe (Windows)
python -V                           # the Python version inside the venv
pip list                            # only what you've installed here

Install packages — they land in .venv/lib/.../site-packages, nowhere else:

bash
pip install requests pytest

Deactivate when you're done:

bash
deactivate

The venv directory still exists; activation is just a PATH change. Re-activate whenever you come back to the project.


3. The Single Most Important Rule

.venv/ must be gitignored. Always. Without exception.

gitignore
# .gitignore
.venv/
venv/
env/

A venv contains absolute paths baked into its scripts, platform-specific binaries, and roughly 100 MB of downloaded packages. None of it is portable. Committing it bloats your repo, breaks for every other developer (different paths, different OS), and adds nothing — the venv is reproducible from your dependency file in seconds.


4. Recording and Restoring Dependencies

A venv is recreated from a list, not committed. Two paths:

The classic — requirements.txt:

bash
pip freeze > requirements.txt
text
# requirements.txt
certifi==2024.2.2
charset-normalizer==3.3.2
idna==3.7
requests==2.31.0
urllib3==2.2.1

pip freeze dumps every installed package, including transitive dependencies, at exact versions. To recreate the environment elsewhere:

bash
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

The modern — pyproject.toml:

If your project has a pyproject.toml (see the packaging lesson), declare dependencies there and install editable:

bash
pip install -e ".[dev]"

This is the better path for any project that will eventually be a package. requirements.txt still has its place — typically as a lockfile generated from pyproject.toml for reproducible installs.


5. Lockfiles — Reproducible Installs

requirements.txt from pip freeze is a snapshot, not a lockfile. It captures versions but not hashes, and doesn't separate direct dependencies from transitive ones.

For real reproducibility, use a lockfile tool:

ToolLockfileNotes
pip-toolsrequirements.txt (compiled)pip-compile reads pyproject.toml (or requirements.in) and produces a fully-pinned, hash-locked requirements.txt.
poetrypoetry.lockBundled with the poetry CLI. Locks transitive deps, supports groups.
uvuv.lockFastest. Reads pyproject.toml, locks with hashes, syncs deterministically.
pdmpdm.lockPEP 582-friendly, similar feature surface to poetry.

A lockfile guarantees that pip install -r requirements.txt (or uv sync) on your colleague's machine, in CI, and in production produces the exact set of packages and versions you tested with. For libraries, you don't need this — loose ranges win. For applications, you do.


6. uv — The Modern Fast Replacement

uv is a Rust-based tool that does everything pip and venv do, ten to a hundred times faster. It's the new default for new projects.

bash
# install once (Homebrew, pipx, or the official installer script)
brew install uv                     # macOS
pipx install uv                     # cross-platform

# inside a project:
uv venv                             # create .venv (faster than python -m venv)
uv pip install requests             # like pip install, but fast
uv pip install -r requirements.txt
uv sync                             # install + lock from pyproject.toml
uv add requests                     # add a dep to pyproject AND install it
uv run pytest                       # run a command in the project's venv, no activation needed

uv run is the killer feature — you don't even have to activate the venv. It detects the project, uses (or creates) the right venv, and runs your command. For day-to-day work, that single command replaces the entire activate/run/deactivate dance.

uv also manages Python versions:

bash
uv python install 3.12              # download a Python interpreter
uv venv --python 3.12               # create a venv using that version

Recommendation: default to uv for new projects. Stick with stock venv + pip if you're contributing to a project that hasn't adopted it.


7. pipx — Global CLI Tools Done Right

Some packages are tools, not libraries — black, ruff, httpie, poetry, pre-commit. You want them available everywhere, but you don't want them dumped into your system Python, and putting them in every project venv is silly.

pipx solves this. It installs each tool into its own isolated venv and links the entry point onto your PATH.

bash
pipx install black
pipx install ruff
pipx install httpie
pipx install uv

pipx list                           # see what's installed
pipx upgrade-all

You get black on your PATH everywhere, with no contamination of any project or system Python. This is the right tool for end-user applications; venv is the right tool for project dependencies. Don't confuse them.


8. conda / mamba — When You Need Non-Python Deps

conda (and the faster reimplementation mamba) is a different kind of environment manager. It handles non-Python dependencies — compiled libraries, CUDA toolkits, R, system tools. Mostly relevant in scientific computing and ML.

bash
conda create -n myenv python=3.12 numpy scipy pytorch
conda activate myenv

For pure-Python projects, conda is overkill — venv or uv is lighter, faster, and integrates better with the rest of the Python ecosystem. Reach for conda when you genuinely need its non-Python package handling (NumPy with Intel MKL, CUDA-linked PyTorch, GDAL with its system libraries).


9. poetry — The All-In-One

poetry bundles env management, dependency resolution, lockfiles, and publishing into a single CLI:

bash
poetry new myproject
poetry add requests
poetry install
poetry run pytest
poetry publish

It's opinionated, well-loved by some, and slower than uv. If you're starting fresh today, uv covers the same ground and runs faster. Poetry is still a reasonable choice if you prefer its workflow — just know it's no longer the obvious modern pick.


10. Python Version Management

Multiple Python versions on one machine is the norm — Python 3.10 for one client's legacy code, 3.12 for a new project, the system Python you should never touch. Use a version manager:

  • pyenv (macOS / Linux) — installs and switches Python versions per-project via a .python-version file.
  • pyenv-win — the Windows fork.
  • uv python install — built into uv. Simplest if you already use uv.
bash
pyenv install 3.12.3
pyenv local 3.12.3                  # writes .python-version in the current dir
python -V                           # Python 3.12.3

What you should not use as your project's Python: the system Python that ships with macOS or comes preinstalled on Linux. Those are the OS's Python — upgrading or breaking them breaks the OS. Always install your own.


11. The Directory Pattern

The convention that scales:

python
~/code/
    project-a/
        .venv/                      # project-a's venv, gitignored
        pyproject.toml
        src/
    project-b/
        .venv/                      # project-b's venv, gitignored
        pyproject.toml
        src/

Every project gets its own .venv inside the project directory. Never share a venv across projects. Never store venvs in a central location like ~/venvs/ — when you delete the project, the venv goes with it; when you move the project, you don't have to update tool configs pointing at a separate venv path.

IDE tip: set the Python interpreter to .venv/bin/python (or .venv\Scripts\python.exe on Windows) explicitly. VS Code and PyCharm auto-detect this if the .venv is at the project root.


Common Mistakes

1. No venv at all

You pip install straight into the system Python. Six months later a different project upgrades a shared dependency, and the original project mysteriously breaks. This is the entire reason venvs exist. Use one from day one — python -m venv .venv is fifteen seconds.

2. Committing .venv/

A 100 MB diff with absolute paths baked into shebangs. Won't even work on a colleague's machine — their Python lives at a different path. Add .venv/ to .gitignore immediately.

3. Mixing global and project installs

bash
pip install pandas                  # was the venv active? unclear

Before any pip install, check which python (or where python on Windows). If it doesn't say .venv, you're not in the venv. The venv name in your prompt is the visual confirmation — (.venv) $ means safe, no prefix means you're about to install globally.

4. Forgetting to activate before installing

Same root cause as the previous one. Easy to do after switching directories or opening a new terminal. which python is the muscle memory.

5. requirements.txt without pinned versions

text
requests
pandas

Works today. Two weeks later, pandas 3.0 releases with breaking changes and your CI explodes. Pin to known-good versions:

text
requests==2.31.0
pandas==2.2.1

For libraries, pin loosely (requests>=2.30,<3); for applications, pin tightly or use a lockfile.

6. Multiple Pythons on PATH, wrong one creates the venv

python -m venv .venv uses whichever python runs first on your PATH. If that's the wrong version, every install after that targets the wrong Python. Be explicit:

bash
python3.12 -m venv .venv
# or with uv:
uv venv --python 3.12

7. venv name collisions in IDEs

PyCharm sometimes auto-creates venv/ while you've made .venv/. Now you have two venvs and the IDE uses one, your terminal uses the other. Pick one name, configure the IDE to use that exact path, delete the duplicate.


🎯 Your Turn — A Setup Script

Write a setup script that bootstraps a fresh dev environment in one command. Both shells, because cross-platform matters.

Requirements — your script should:

1. Create a .venv/ in the current directory using python -m venv .venv.
2. Activate it for the script's session.
3. Upgrade pip to the latest version.
4. Install everything from requirements.txt.
5. Print a final "done — activate with …" hint.

Write setup.sh (bash, for macOS/Linux) and setup.ps1 (PowerShell, for Windows).

Skeleton — setup.sh:

bash
#!/usr/bin/env bash
set -euo pipefail

# TODO 1: create .venv
# TODO 2: activate it
# TODO 3: upgrade pip
# TODO 4: install requirements.txt if it exists
# TODO 5: print a friendly hint

Skeleton — setup.ps1:

powershell
$ErrorActionPreference = "Stop"

# TODO 1-5 as above, with PowerShell syntax
Hint 1 — Activation inside a script In bash, source .venv/bin/activate works because the script runs in a single shell. In PowerShell, . .\.venv\Scripts\Activate.ps1 dot-sources the activation script. After activation, pip automatically points at the venv's copy.
Hint 2 — Conditional file check Bash: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi. PowerShell: if (Test-Path requirements.txt) { pip install -r requirements.txt }.
Show full solution

setup.sh — bash (macOS / Linux):

bash
#!/usr/bin/env bash
set -euo pipefail

PYTHON="${PYTHON:-python3}"

echo "==> Creating .venv with $PYTHON"
$PYTHON -m venv .venv

# shellcheck disable=SC1091
source .venv/bin/activate

echo "==> Upgrading pip"
python -m pip install --upgrade pip

if [ -f requirements.txt ]; then
    echo "==> Installing requirements.txt"
    pip install -r requirements.txt
elif [ -f pyproject.toml ]; then
    echo "==> Installing project (editable, with dev extras)"
    pip install -e ".[dev]" || pip install -e .
else
    echo "==> No requirements.txt or pyproject.toml found — skipping install."
fi

echo ""
echo "Done. Activate the venv with:"
echo "    source .venv/bin/activate"

Make it executable and run:

bash
chmod +x setup.sh
./setup.sh

setup.ps1 — PowerShell (Windows):

powershell
$ErrorActionPreference = "Stop"

$Python = if ($env:PYTHON) { $env:PYTHON } else { "python" }

Write-Host "==> Creating .venv with $Python"
& $Python -m venv .venv

Write-Host "==> Activating .venv"
. .\.venv\Scripts\Activate.ps1

Write-Host "==> Upgrading pip"
python -m pip install --upgrade pip

if (Test-Path requirements.txt) {
    Write-Host "==> Installing requirements.txt"
    pip install -r requirements.txt
} elseif (Test-Path pyproject.toml) {
    Write-Host "==> Installing project (editable, with dev extras)"
    try { pip install -e ".[dev]" } catch { pip install -e . }
} else {
    Write-Host "==> No requirements.txt or pyproject.toml found — skipping install."
}

Write-Host ""
Write-Host "Done. Activate the venv with:"
Write-Host "    .\.venv\Scripts\Activate.ps1"

Run it (you may need to allow script execution once):

powershell
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned
.\setup.ps1

A clean clone of the project now becomes one command for any new developer — and the script is short enough to read top-to-bottom and trust. Bonus points if you commit it alongside the project's README, with a single line: "Run ./setup.sh (or .\setup.ps1) and you're done."


What You Learned

  • A virtual environment is a per-project Python install with its own site-packages. One per project, always.
  • Create with python -m venv .venv. Activate with source .venv/bin/activate (or Activate.ps1 on Windows). Deactivate with deactivate.
  • Always gitignore .venv/. Recreate from requirements.txt or pyproject.toml, never commit the directory.
  • pip freeze > requirements.txt captures a snapshot. Lockfiles (pip-tools, uv.lock, poetry.lock) give true reproducibility.
  • uv is the modern, fast, Rust-based replacement for pip + venv. uv run removes the activation step entirely.
  • pipx installs CLI tools (black, ruff, httpie) in their own isolated venvs — global availability without contamination.
  • conda is for non-Python dependencies (CUDA, MKL, GDAL); skip it for pure-Python projects.
  • Use a Python version manager (pyenv, uv python) rather than the system Python.
  • Before every pip install, check which python — if it doesn't show .venv, you're about to install globally.

Next: round out the How-to path with deployment and ops topics, or revisit the packaging lesson to publish your first project to PyPI.