Virtual Environments & pip
Every Python project should run inside its own isolated environment. Without one, every install lands in one shared Python installation, where packages from different projects collide and version conflicts accumulate silently. This lesson teaches the modern tool first — uv — then shows venv/pip underneath, because uv manages both for you but every project you touch that predates 2024 still uses the raw tools directly.
This lesson has no Run button —
uv/venv/pip must run locally:uv init myapp && cd myapp && uv add requests.
uv — one tool for interpreters, environments, and dependencies
Section titled “uv — one tool for interpreters, environments, and dependencies”uv is a single binary that replaces venv + pip + pip-tools + pyenv + pipx. It resolves and installs roughly an order of magnitude faster than pip because it’s written in Rust and caches aggressively across projects.
# create a new project: pyproject.toml, .python-version, src/ layout, git inituv init myapp && cd myapp# pyproject.toml (generated by uv init)[project]name = "myapp"version = "0.1.0"description = "Add your description here"readme = "README.md"requires-python = ">=3.14"dependencies = []uv init also writes a .python-version file — real output on this machine:
$ cat .python-version3.14Every uv run/uv sync in this directory (and subdirectories) now targets that interpreter version, without you setting an environment variable or activating anything.
Adding, removing, and running
Section titled “Adding, removing, and running”uv add requests # resolve, download, install, record in pyproject.toml + uv.lockuv remove requests # uninstall + remove from pyproject.toml + relockuv run python main.py # run inside the project's environment -- no activation neededReal run, adding requests to a fresh project:
$ uv add requestsUsing CPython 3.14.6Creating virtual environment at: .venvResolved 6 packages in 367msInstalled 5 packages in 10ms + certifi==2026.7.22 + charset-normalizer==3.5.1 + idna==3.20 + requests==2.34.2 + urllib3==2.8.0uv add created .venv on its own — you never ran python -m venv or activated anything. uv run uses that environment automatically:
$ echo 'import requestsprint(requests.__version__)' > main.py$ uv run python main.py2.34.2Removing works the same way, in reverse — uv remove requests uninstalls the package and every dependency that only requests needed, and rewrites pyproject.toml:
$ uv remove requestsResolved 1 package in 3msUninstalled 5 packages in 12ms - certifi==2026.7.22 - charset-normalizer==3.5.1 - idna==3.20 - requests==2.34.2 - urllib3==2.8.0uv.lock — the cross-platform lockfile
Section titled “uv.lock — the cross-platform lockfile”uv add also wrote uv.lock, which records every resolved package, its version, and its file hash — for every platform, not just the one you’re on. This is the one thing plain requirements.txt never did well.
$ head -12 uv.lockversion = 1revision = 3requires-python = ">=3.14"
[[package]]name = "certifi"version = "2026.7.22"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },]uv sync reproduces the environment from uv.lock exactly, on any machine or CI runner:
$ uv syncResolved 6 packages in 3msChecked 5 packages in 0.11msuv lock --check verifies pyproject.toml and uv.lock still agree, without installing anything — the check to run in CI before uv sync --frozen:
$ uv lock --checkResolved 6 packages in 3msCommit both pyproject.toml and uv.lock to version control. Never commit .venv/.
uv tool / uvx — run a CLI tool without polluting your project
Section titled “uv tool / uvx — run a CLI tool without polluting your project”Some packages are tools you run (ruff, mypy, httpie), not libraries your code imports. uv tool install puts one in its own isolated environment and links its executable onto your PATH; uvx runs a tool once, ephemerally, with no persistent install at all — this course’s own commands (uvx ruff, uvx mypy) use exactly this.
$ uv tool install ruffResolved 1 package in 268msInstalled 1 package in 4ms + ruff==0.16.8Installed 1 executable: ruff
$ uvx ruff --versionruff 0.16.8uv python — managing interpreters themselves
Section titled “uv python — managing interpreters themselves”$ uv python list --only-installedcpython-3.14.7-macos-aarch64-none /opt/homebrew/bin/python3.14 -> ../Cellar/[email protected]/3.14.7/bin/python3.14cpython-3.14.6-macos-aarch64-none /Users/.../uv/python/cpython-3.14-macos-aarch64-none/bin/python3.14cpython-3.13.14-macos-aarch64-none /opt/homebrew/bin/python3.13 -> ../Cellar/[email protected]/3.13.14/bin/python3.13uv can download and manage its own copies of CPython (cpython-3.14.6 above came from uv python install, triggered automatically by uv add when no interpreter satisfied requires-python) — completely separate from any system Python or Homebrew install. uv python pin 3.14 writes .python-version; every teammate and CI runner that also uses uv reproduces the same interpreter, not “whatever python3 happens to resolve to.”
uv pip — the escape hatch for pip-shaped workflows
Section titled “uv pip — the escape hatch for pip-shaped workflows”For scripts or CI steps that still expect pip’s exact interface, uv pip is a drop-in, much faster reimplementation that operates on the same virtual environment:
$ uv pip listPackage Version------------------ ---------certifi 2026.7.22charset-normalizer 3.5.1idna 3.20requests 2.34.2urllib3 2.8.0
$ uv pip install --dry-run tomliResolved 1 package in 630msWould download 1 packageWould install 1 package + tomli==2.4.1uv pip does not update pyproject.toml or uv.lock — it’s for compatibility with existing pip-based tooling, not for managing a uv-native project. Prefer uv add/uv remove for anything you intend to keep.
What’s underneath: venv and pip
Section titled “What’s underneath: venv and pip”uv manages a venv-style virtual environment and calls a pip-compatible resolver for you. Understanding the raw tools matters because most Python code you’ll read — tutorials, older repos, CI configs — still uses them directly.
# create a virtual environment in .venv/, with the stdlib venv modulepython -m venv .venvActivation is not magic — it only prepends the environment’s bin/ directory to your shell’s PATH, so unqualified python and pip resolve to the isolated copies instead of a system interpreter:
$ echo "$PATH" | cut -c1-90 # before activation/opt/homebrew/opt/postgresql-client/bin:/Users/avetavos/google-cloud-sdk/bin:/Users/av
$ source .venv/bin/activate$ echo "$PATH" | cut -c1-90 # after activation -- .venv/bin prepended/private/tmp/.../plainvenv/.venv/bin:/opt/homebrew/opt/postgresql-client/bin:/Users/avNothing else changes: no new process, no interpreter magically swapped out from under you. deactivate (a shell function activate defines) just restores the old PATH.
python -m pip vs bare pip
Section titled “python -m pip vs bare pip”$ which python.../plainvenv/.venv/bin/python$ which pip.../plainvenv/.venv/bin/pip$ python -m pip --versionpip 26.2.1 from .../plainvenv/.venv/lib/python3.14/site-packages/pip (python 3.14)$ pip --versionpip 26.2.1 from .../plainvenv/.venv/lib/python3.14/site-packages/pip (python 3.14)Inside an active, correctly-built venv the two are identical, because pip on PATH is that interpreter’s pip. They diverge the moment PATH lies to you — a broken activation, a sudo-installed pip shadowing the venv’s own, or a script that forgot to activate first. python -m pip always installs into the interpreter that ran it, because Python resolves the module against sys.executable, not against whatever pip a stale PATH entry happens to find. Never run sudo pip install — it installs into the system Python that macOS/Linux tooling itself depends on, with root permissions, bypassing any virtual environment entirely; a broken system Python from a botched sudo pip can take out unrelated OS tooling.
pip install -e . — editable installs
Section titled “pip install -e . — editable installs”$ python -m pip install -e . --quiet$ python -c "import edemo; print(edemo.greet())"v1$ sed -i '' 's/v1/v2 (edited, no reinstall)/' src/edemo/__init__.py$ python -c "import edemo; print(edemo.greet())"v2 (edited, no reinstall)-e (editable/development mode) installs a link to your source tree instead of copying files — edits take effect on the next import, with no reinstall step. This is how you develop a package you’re also using from a separate script or test suite.
pip freeze vs a real lockfile
Section titled “pip freeze vs a real lockfile”$ python -m pip install requests --quiet$ pip freezecertifi==2026.7.22charset-normalizer==3.5.1idna==3.20requests==2.34.2urllib3==2.8.0pip freeze > requirements.txt captures exact versions of what happens to be installed right now — no file hashes, no per-platform wheel resolution, no distinction between “I asked for this” and “this got pulled in as a dependency of something else.” uv.lock records hashes and platform-specific wheels for every package by default; a plain requirements.txt only gets hashes if you explicitly run pip-compile --generate-hashes or uv export --no-hashes=false — most requirements.txt files in the wild have none, which means pip install -r requirements.txt will happily install a tampered package with the right version number and a different file.
flowchart TD A["uv init myapp"] --> B["uv add requests"] B --> C[".venv created + pyproject.toml + uv.lock written"] C --> D["uv run ... / uv sync"] D -->|"reproduces exactly"| E["teammate or CI: uv sync --frozen"] F["python -m venv .venv"] --> G["source .venv/bin/activate (PATH only)"] G --> H["python -m pip install ..."] H --> I["pip freeze > requirements.txt (no hashes, no per-platform lock)"]
Mental Model
Section titled “Mental Model”uv is not a new package format — it is a faster implementation of the same three jobs pyenv + venv + pip split across three tools: fetch an interpreter, create an isolated environment, resolve and install packages into it. uv.lock is what pip freeze’s output was always trying to be: a description of the entire dependency graph, pinned by hash, for every target platform, not just a snapshot of one machine’s site-packages right now.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
uv add/uv run | No manual venv creation or activation; project-scoped by construction | Another tool to install (though it’s a single static binary) |
uv.lock | Hash-pinned, cross-platform, reproducible everywhere | Not human-editable the way requirements.txt is; needs uv (or uv export) to consume |
uv pip compatibility layer | Drop into pip-shaped CI/scripts with a faster resolver | Bypasses pyproject.toml/uv.lock bookkeeping — not for managing a uv-native project |
venv + pip directly | Works with only the stdlib + one PyPI package, zero extra install | Manual activation, no built-in lockfile, easy to pip install into the wrong interpreter |
pip freeze > requirements.txt | Simple, human-readable | No hashes by default, no per-platform resolution, mixes direct and transitive dependencies |
Common Misconceptions
Section titled “Common Misconceptions”- “Activating a venv starts a new Python process.” It only edits
PATH(and a couple of shell variables) in your current shell — no process starts until you next runpython. - “
pipandpython -m pipare always interchangeable.” They agree only whenPATHcorrectly pointspipat the active interpreter’s own copy;python -m pipis the version that can’t be fooled by a stale or shadowedPATH. - “
uv.lockis optional, likerequirements.txtalways was.”uv sync/uv runread it to reproduce your exact environment; deleting it (or skipping the commit) reintroduces the “works on my machine” problemuv.lockexists to remove. - “
requirements.txtalways pins exact, verifiable packages.” It pins version numbers by convention only — without--generate-hashes, nothing stopspipfrom installing a different file under the same version string from a compromised or re-uploaded index. - “
sudo pip installis a reasonable fix for a permission error.” A permission error installing a package almost always means you’re not inside a virtual environment; the fix is to create and activate one, not to install as root into the system Python.
Reading AI-generated code
Section titled “Reading AI-generated code”Before trusting a generated setup script or requirements.txt, check for:
sudo pip install ...anywhere in a script or README — a virtual environment was skipped, not needed less.- A
requirements.txtwith unpinned entries (requestswith no version at all) sitting next to a claim that the environment is “reproducible.” subprocess.run(["pip", "install", ...])called from inside application code at runtime — dependencies belong inpyproject.toml/requirements.txt, installed before the program runs, never as a side effect of running it.- A setup script that activates a venv and then calls
pip(notpython -m pip) inside a subprocess withshell=False— activation only affects the current shell’sPATH, so a barepipin a fresh subprocess can silently resolve to the system pip instead.
🐛 Spot the bug
import subprocessimport sys
def ensure_dependency(package: str) -> None: # BUG: installs into whatever interpreter/environment happens to be # active when this function runs, and does it every time the program # starts -- not once, at setup, into a lockfile-controlled environment. subprocess.run(["pip", "install", package], check=True)
def main() -> None: ensure_dependency("requests") import requests
print(requests.get("https://example.com", timeout=5).status_code)
if __name__ == "__main__": main()Show the answer
Installing dependencies from inside main() with subprocess.run(["pip", "install", ...]) means every run of the program can silently install or upgrade a package, with no lockfile, no pin, and no record of what changed — exactly the “works on my machine” failure mode uv.lock/requirements.txt exist to prevent. It also calls bare pip rather than python -m pip (or sys.executable), so in a subprocess with a different or missing PATH it can install into the wrong interpreter entirely, or fail outright. The fix: declare requests as a dependency in pyproject.toml (uv add requests), install it once as a setup step (uv sync), and let the program simply import requests and fail loudly at startup if it’s missing.