Skip to content

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.

Terminal window
# create a new project: pyproject.toml, .python-version, src/ layout, git init
uv 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-version
3.14

Every uv run/uv sync in this directory (and subdirectories) now targets that interpreter version, without you setting an environment variable or activating anything.

Terminal window
uv add requests # resolve, download, install, record in pyproject.toml + uv.lock
uv remove requests # uninstall + remove from pyproject.toml + relock
uv run python main.py # run inside the project's environment -- no activation needed

Real run, adding requests to a fresh project:

$ uv add requests
Using CPython 3.14.6
Creating virtual environment at: .venv
Resolved 6 packages in 367ms
Installed 5 packages in 10ms
+ certifi==2026.7.22
+ charset-normalizer==3.5.1
+ idna==3.20
+ requests==2.34.2
+ urllib3==2.8.0

uv add created .venv on its own — you never ran python -m venv or activated anything. uv run uses that environment automatically:

$ echo 'import requests
print(requests.__version__)' > main.py
$ uv run python main.py
2.34.2

Removing 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 requests
Resolved 1 package in 3ms
Uninstalled 5 packages in 12ms
- certifi==2026.7.22
- charset-normalizer==3.5.1
- idna==3.20
- requests==2.34.2
- urllib3==2.8.0

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.lock
version = 1
revision = 3
requires-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 sync
Resolved 6 packages in 3ms
Checked 5 packages in 0.11ms

uv 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 --check
Resolved 6 packages in 3ms

Commit 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 ruff
Resolved 1 package in 268ms
Installed 1 package in 4ms
+ ruff==0.16.8
Installed 1 executable: ruff
$ uvx ruff --version
ruff 0.16.8

uv python — managing interpreters themselves

Section titled “uv python — managing interpreters themselves”
$ uv python list --only-installed
cpython-3.14.7-macos-aarch64-none /opt/homebrew/bin/python3.14 -> ../Cellar/[email protected]/3.14.7/bin/python3.14
cpython-3.14.6-macos-aarch64-none /Users/.../uv/python/cpython-3.14-macos-aarch64-none/bin/python3.14
cpython-3.13.14-macos-aarch64-none /opt/homebrew/bin/python3.13 -> ../Cellar/[email protected]/3.13.14/bin/python3.13

uv 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 list
Package Version
------------------ ---------
certifi 2026.7.22
charset-normalizer 3.5.1
idna 3.20
requests 2.34.2
urllib3 2.8.0
$ uv pip install --dry-run tomli
Resolved 1 package in 630ms
Would download 1 package
Would install 1 package
+ tomli==2.4.1

uv 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.

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.

Terminal window
# create a virtual environment in .venv/, with the stdlib venv module
python -m venv .venv

Activation 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/av

Nothing else changes: no new process, no interpreter magically swapped out from under you. deactivate (a shell function activate defines) just restores the old PATH.

$ which python
.../plainvenv/.venv/bin/python
$ which pip
.../plainvenv/.venv/bin/pip
$ python -m pip --version
pip 26.2.1 from .../plainvenv/.venv/lib/python3.14/site-packages/pip (python 3.14)
$ pip --version
pip 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.

$ 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.

$ python -m pip install requests --quiet
$ pip freeze
certifi==2026.7.22
charset-normalizer==3.5.1
idna==3.20
requests==2.34.2
urllib3==2.8.0

pip 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)"]
uv-native flow vs the venv+pip flow underneath

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.

What you getBenefitCost
uv add/uv runNo manual venv creation or activation; project-scoped by constructionAnother tool to install (though it’s a single static binary)
uv.lockHash-pinned, cross-platform, reproducible everywhereNot human-editable the way requirements.txt is; needs uv (or uv export) to consume
uv pip compatibility layerDrop into pip-shaped CI/scripts with a faster resolverBypasses pyproject.toml/uv.lock bookkeeping — not for managing a uv-native project
venv + pip directlyWorks with only the stdlib + one PyPI package, zero extra installManual activation, no built-in lockfile, easy to pip install into the wrong interpreter
pip freeze > requirements.txtSimple, human-readableNo hashes by default, no per-platform resolution, mixes direct and transitive dependencies
  • “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 run python.
  • “pip and python -m pip are always interchangeable.” They agree only when PATH correctly points pip at the active interpreter’s own copy; python -m pip is the version that can’t be fooled by a stale or shadowed PATH.
  • “uv.lock is optional, like requirements.txt always was.” uv sync/uv run read it to reproduce your exact environment; deleting it (or skipping the commit) reintroduces the “works on my machine” problem uv.lock exists to remove.
  • “requirements.txt always pins exact, verifiable packages.” It pins version numbers by convention only — without --generate-hashes, nothing stops pip from installing a different file under the same version string from a compromised or re-uploaded index.
  • “sudo pip install is 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.

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.txt with unpinned entries (requests with 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 in pyproject.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 (not python -m pip) inside a subprocess with shell=False — activation only affects the current shell’s PATH, so a bare pip in a fresh subprocess can silently resolve to the system pip instead.

🐛 Spot the bug

import subprocess
import 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.

What does uv add requests do that python -m venv .venv && pip install requests does not?
What does activating a virtual environment (source .venv/bin/activate) actually change?
What does the second import print, and why?
$ python -m pip install -e .
$ python -c "import edemo; print(edemo.greet())"
v1
$ sed -i '' 's/v1/v2/' src/edemo/__init__.py
$ python -c "import edemo; print(edemo.greet())"
Why is a plain pip freeze > requirements.txt a weaker reproducibility guarantee than uv.lock?
What is wrong with this pattern?
def ensure_dependency(package: str) -> None:
    subprocess.run(["pip", "install", package], check=True)

def main() -> None:
    ensure_dependency("requests")
    import requests
Why is sudo pip install requests considered dangerous rather than just unnecessary?