Skip to content

Instantly share code, notes, and snippets.

@basperheim
Last active December 15, 2025 18:04
Show Gist options
  • Select an option

  • Save basperheim/63f6ece1ba33d80dc1b7fdf485d4a8db to your computer and use it in GitHub Desktop.

Select an option

Save basperheim/63f6ece1ba33d80dc1b7fdf485d4a8db to your computer and use it in GitHub Desktop.
macOS + Homebrew + Python venvs (Pinned to 3.12 for Pygame) — A Practical Setup + Gotchas

macOS + Homebrew + Python venvs (Pinned to 3.12 for Pygame) — A Practical Setup + Gotchas

This gist is the "do it once, stop suffering" setup for macOS (Apple Silicon) where:

  • Homebrew's default python3 is too new for your project (ex: Python 3.14 breaks/doesn't have stable wheels for stuff like Pygame yet)
  • macOS still has /usr/bin/python3 (often 3.9.x) and it silently wins when Homebrew isn't configured how you think
  • you want a project-local venv that is always Python 3.12, with pip installs going into the venv (no PEP 668 drama)
  • you also want IDLE/Tkinter to work on 3.12 (optional)

This documents the exact failures I hit and the final working config.


What happened (high level)

I hit these recurring issues:

  1. macOS defaulted to /usr/bin/python3 (3.9.6) Even with Homebrew installed, python3 -V was still 3.9.6 because Homebrew's python3 shim wasn't in play.

  2. PEP 668 externally-managed-environment error inside a "venv" That error shows up when pip thinks you're trying to install into a managed system environment (Homebrew Python), not a venv.

    Root cause was split-brain Python:

    • pip sometimes resolved to the venv
    • python resolved to Homebrew/global (or vice-versa)
    • and/or ~/.zshrc re-sourced mid-session shuffled PATH
  3. Aliases for python / python3 broke venv behavior Aliases override PATH resolution. If you alias python=python3.12, you can accidentally bypass .venv/bin/python even when a venv is active.

  4. Homebrew's "python" vs "python@3.12" behavior is confusing

    • brew install python installs the current Homebrew Python (in my case: 3.14.x) and creates /opt/homebrew/bin/python3
    • brew install python@3.12 installs versioned Python 3.12, but it does NOT necessarily create /opt/homebrew/bin/python3

    So you can have Python 3.12 installed and still have python3 defaulting to /usr/bin/python3 or Homebrew 3.14.

  5. IDLE failed in the 3.12 venv: "IDLE can't import Tkinter" Tkinter is not a pip thing. It's a build / linkage thing for the base interpreter. Fixed by installing the matching Homebrew Tk package for 3.12.


The working strategy (the one that finally stopped the pain)

Rule #1: Don't fight global python3. Pin per-project.

Homebrew wants python3 to mean "latest stable" (3.14 now). Pygame often lags on brand new Python minors.

So instead of trying to force global python3 to 3.12, you:

  • install python@3.12
  • create each project venv using the explicit 3.12 interpreter
  • inside venv, run python -m pip ... (or .venv/bin/python -m pip ...)
  • never use --break-system-packages for normal work

Result: global Python can be 3.14 (or Apple 3.9), and your project still runs on 3.12.


Install steps

1) Install Homebrew Python 3.12 (versioned)

brew install python@3.12

Confirm it exists:

/opt/homebrew/opt/python@3.12/bin/python3.12 -V

2) (Optional) Install Tkinter/IDLE support for Python 3.12

If you want python -m idlelib to work with the 3.12 interpreter:

brew install python-tk@3.12

Verify:

/opt/homebrew/opt/python@3.12/bin/python3.12 -c "import tkinter; print(tkinter.TkVersion)"

Shell configuration (what to put where)

✅ Put Homebrew shell environment in ~/.zprofile (login shell)

This makes Homebrew paths consistent and avoids repeatedly stacking PATH entries.

~/.zprofile

# Homebrew environment (Apple Silicon default path)
eval "$(/opt/homebrew/bin/brew shellenv)"

Why: ~/.zprofile runs for login shells; ~/.zshrc runs for interactive shells. Using brew shellenv is cleaner than manually prepending /opt/homebrew/bin repeatedly.

✅ Keep project helpers/functions in ~/.zshrc

Key rule: do NOT alias python or python3. Ever. If you want a shortcut, alias a named command like py312, not python.


DO NOT do this (it caused most of the chaos)

alias python='python3.12'
alias python3='python3.12'

Why it's bad:

  • it overrides the venv's .venv/bin/python even after activation
  • you end up running global Python with venv pip (or the reverse)
  • you'll see PEP 668 errors and/or installs going to the wrong place

The venv312 function (final version)

Put this in ~/.zshrc.

It guarantees:

  • venv is created using Homebrew 3.12
  • installs are done using the venv interpreter, not PATH guessing
  • no accidental interaction with /usr/bin/python3 or Homebrew 3.14
# Create/refresh a venv using Homebrew Python 3.12, then optionally install deps.
# Usage:
#   venv312            # create .venv (or reuse) + activate + optional deps install
#   venv312 --recreate # delete .venv first
venv312() {
  local py="/opt/homebrew/opt/python@3.12/bin/python3.12"
  local recreate=0

  if [[ "${1:-}" == "--recreate" ]]; then
    recreate=1
  fi

  if [[ ! -x "$py" ]]; then
    echo "ERROR: Homebrew Python 3.12 not found at: $py" >&2
    echo "Fix: brew install python@3.12" >&2
    return 1
  fi

  if (( recreate )); then
    rm -rf .venv
  fi

  # Always (re)create the venv using the explicit 3.12 interpreter if missing.
  if [[ ! -d .venv ]]; then
    "$py" -m venv .venv || return 1
  fi

  # Activate for your prompt, env vars, etc.
  source .venv/bin/activate || return 1

  # From here on, NEVER rely on `python`/`pip` resolution. Always pin to the venv.
  local vpy=".venv/bin/python"
  if [[ ! -x "$vpy" ]]; then
    echo "ERROR: venv python not found at: $vpy" >&2
    echo "Fix: rm -rf .venv && $py -m venv .venv" >&2
    return 1
  fi

  "$vpy" -m pip install -U pip >/dev/null || return 1

  # Install deps if common files exist (in priority order)
  if [[ -f requirements-dev.txt ]]; then
    "$vpy" -m pip install -r requirements-dev.txt || return 1
  elif [[ -f requirements.txt ]]; then
    "$vpy" -m pip install -r requirements.txt || return 1
  elif [[ -f pyproject.toml ]]; then
    # Only works if the project is set up for it; harmless if it isn't.
    "$vpy" -m pip install -e . || return 1
  else
    echo "No requirements.txt / requirements-dev.txt / pyproject.toml found. Skipping dependency install."
  fi
}

The pywho() sanity checker (prevents "I swear I'm in a venv" lies)

Put this in ~/.zshrc:

pywho() {
  echo "VIRTUAL_ENV=${VIRTUAL_ENV:-<none>}"
  type -a python python3 pip 2>/dev/null || true
  command -v python >/dev/null && python -c "import sys; print(sys.executable)"
  command -v python >/dev/null && python -m pip -V
}

Use it whenever something smells off:

pywho

What "good" looks like in a venv

  • sys.executable points into .venv/...
  • python -m pip -V points into .venv/...

If either points to /usr/bin or /opt/homebrew/... outside .venv, you're not actually using the venv interpreter.


PEP 668: the externally-managed-environment error (why it happened)

That error is pip protecting Homebrew's system-managed Python.

If you see it, you are not installing into your venv (even if your prompt shows (.venv)).

Common causes I hit:

  • python or python3 aliases bypassed the venv interpreter
  • re-sourcing ~/.zshrc while a venv is active reordered PATH and broke resolution
  • running pip directly instead of python -m pip made it easier to hit the wrong pip

Fix pattern

  1. Run pywho
  2. If split-brain exists: remove aliases, stop re-sourcing .zshrc, recreate venv with venv312
  3. Never "solve" this by globally enabling --break-system-packages

Using --break-system-packages is a last resort for global installs. It is NOT the normal path for dev.


"Gotcha": Re-sourcing ~/.zshrc mid-venv

I repeatedly ran:

source ~/.zshrc

...while in (.venv).

This can:

  • prepend Homebrew paths again
  • reintroduce aliases / functions
  • break the venv's PATH ordering

Best practice: after changing shell config, open a new terminal tab/window.


"Gotcha": Why python3 didn't become Homebrew 3.12

Even with python@3.12 installed and linked, I observed:

  • /opt/homebrew/bin/python3 was missing
  • python3 fell back to /usr/bin/python3 (3.9.6)

That's expected-ish:

  • python@3.12 is versioned and doesn't necessarily provide the unversioned python3 shim
  • brew install python provides /opt/homebrew/bin/python3 (but it's currently 3.14.x)

Takeaway: don't rely on python3 being 3.12 globally. Use the explicit 3.12 binary to create venvs.


Pygame + "new Python breaks things"

This wasn't theoretical — it bit me. Pygame (and other compiled/wheeled deps) often lag behind brand-new Python minors. The result can be:

  • wheels not available yet for the new version
  • crashes, missing dependencies, build failures
  • "works on 3.12, breaks on 3.14" situations

Practical policy: if you depend on native extensions (Pygame, numpy stacks, etc.), stick to a conservative Python version for projects and upgrade when your deps catch up.


IDLE/Tkinter notes

Symptom

python -m idlelib
# ** IDLE can't import Tkinter. Your Python may not be configured for Tk. **

Fix (for Python 3.12 specifically)

brew install python-tk@3.12

Verify:

/opt/homebrew/opt/python@3.12/bin/python3.12 -c "import tkinter; print(tkinter.TkVersion)"

Important: Tkinter is NOT "per venv"

It's tied to the base Python build + its linked Tk libraries. Once installed for 3.12, any venv created from that interpreter can use it.


Quick "do this every time" workflow (the boring reliable one)

From project root:

rm -rf .venv        # only when you want a clean rebuild
venv312             # creates + activates + installs deps
pywho               # sanity check
python main.py

Debug checklist (when things feel cursed)

  1. Check interpreter + pip:
pywho
  1. If you see /usr/bin/python3 or Homebrew 3.14 and you expected venv:
  • deactivate + re-run venv312 --recreate
  1. If you see PEP 668 error:
  • you are installing into a managed Python (not the venv)
  • fix resolution; do NOT "solve" it with global break flags
  1. If IDLE complains about Tkinter:
brew install python-tk@3.12

Timeline of the actual pain (abridged)

I hit these milestones in order:

  • removed .venv, created a venv with python3 -m venv .venv (accidentally using Apple 3.9)
  • tried pip installs → got PEP 668 error → temporarily used --break-system-packages (worked, but wrong)
  • discovered virtualenv command wasn't installed (irrelevant; venv is fine)
  • discovered alias + re-sourcing .zshrc caused split-brain Python/pip
  • removed python aliases and stopped executing debug commands inside .zshrc
  • realized python@3.12 doesn't create /opt/homebrew/bin/python3
  • accepted global python3 might be 3.14 while projects are pinned to 3.12
  • fixed IDLE by installing python-tk@3.12

Final recommendation

  • Keep global Homebrew python3 as whatever Homebrew wants (3.14+).
  • Pin each project to the Python you actually trust (3.12) via venv312.
  • Never alias python/python3.
  • Use pywho whenever something smells off.
  • Don't re-source ~/.zshrc while a venv is active (start a new shell instead).
  • Only use Tkinter/IDLE if you install the matching python-tk@X.Y.

That setup is stable, boring, and doesn't require fighting Homebrew upgrades.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment