---
title: "macOS Python Version — System Python 3.9 vs Homebrew in Claude Code Hooks"
aliases: [macos-python-hooks, homebrew-python-hooks, python-version-hooks, python-union-type-syntax]
tags: [python, macos, claude-code, hooks, homebrew, migration]
sources:
  - "daily/2026-04-22.md"
created: 2026-04-22
updated: 2026-04-22
---

# macOS Python Version — System Python 3.9 vs Homebrew in Claude Code Hooks

macOS ships with a system Python at `/usr/bin/python3` that is locked to Python 3.9. Homebrew-installed Python (typically at `/opt/homebrew/bin/python3`) can be Python 3.12, 3.13, or 3.14. Claude Code hooks that use Python 3.10+ syntax (such as the `X | Y` union type notation for type hints) will silently exit with an error when called by `python3` if the system Python is resolved. The fix is to use the explicit Homebrew Python path in all hook commands.

## Key Points

- **macOS system `/usr/bin/python3` = Python 3.9** on modern Macs — this is a locked Apple-provided stub, not the full CPython distribution
- **`Path | None` union syntax requires Python 3.10+** — using `|` for type unions (PEP 604) fails with `SyntaxError` on Python 3.9
- **Hooks silently fail:** Claude Code hooks execute the command in a subprocess; if the Python script raises a `SyntaxError` on startup, the hook exits non-zero and the status message disappears — no visible error to the user
- **Fix:** replace all `python3` in `~/.claude/settings.json` hook commands with `/opt/homebrew/bin/python3`
- After the fix, `xlsxwriter` or other third-party packages must also be installed for the Homebrew Python: `/opt/homebrew/bin/pip3 install <package>`

## Details

### Why the System Python Is Stuck at 3.9

Apple ships a Python shim at `/usr/bin/python3` that redirects to an Xcode Command Line Tools Python. This Python version is controlled by Apple's release cycle and is not updated via system updates. On macOS Sequoia (15.x) and Sonoma (14.x), this stub typically resolves to Python 3.9.x. Homebrew installs its own CPython build at `/opt/homebrew/bin/python3` that can be any version managed by `brew upgrade`.

The discrepancy becomes critical when scripts use syntax introduced in Python 3.10 or later:

```python
# PEP 604: union types with | operator — requires Python 3.10+
from pathlib import Path

def process(file: Path | None) -> str | None:
    ...

# Python 3.9 equivalent (works on both):
from typing import Optional
def process(file: Optional[Path]) -> Optional[str]:
    ...
```

A hook script with `Path | None` syntax imports cleanly under Homebrew Python 3.14 but raises `SyntaxError: unsupported operand type(s) for |` under system Python 3.9.

### The Silent Failure Mode

Claude Code hooks show a status message while running (e.g., "Saving session to knowledge base..."). If the hook exits with a non-zero code, the status message disappears. The session appears to end normally. No error is displayed to the user in the chat interface.

This makes Python version issues particularly hard to diagnose:
1. SessionEnd hook fires
2. `/usr/bin/python3 hook.py` is called
3. Python 3.9 raises `SyntaxError` on line 3
4. Hook exits with code 1
5. Status message disappears
6. Nothing is written to `daily/YYYY-MM-DD.md`
7. User notices days later that no sessions have been logged

### The Fix: Explicit Homebrew Python Path

In `~/.claude/settings.json`, replace every occurrence of `python3` (bare command) with `/opt/homebrew/bin/python3`:

```json
{
  "hooks": {
    "SessionEnd": [
      {
        "hooks": [{
          "type": "command",
          "command": "CLAUDE_INVOKED_BY=memory_flush /opt/homebrew/bin/python3 ~/.claude/memory-compiler/hooks/session-end.py",
          "statusMessage": "Saving session to knowledge base...",
          "timeout": 10
        }]
      }
    ],
    "SessionStart": [
      {
        "hooks": [{
          "type": "command",
          "command": "/opt/homebrew/bin/python3 ~/.claude/obsidian-session-start.py ...",
          "statusMessage": "Loading Obsidian context..."
        }]
      }
    ]
  }
}
```

### Installing Packages for Homebrew Python

After switching to Homebrew Python, packages installed via system pip may not be available. Install them explicitly for the Homebrew Python:

```bash
/opt/homebrew/bin/pip3 install xlsxwriter
/opt/homebrew/bin/pip3 install python-dotenv
# Or via uv (recommended for the memory-compiler)
uv run --directory ~/.claude/memory-compiler python -c "import xlsxwriter"
```

For the memory-compiler project specifically, `uv` manages its own virtualenv and is not affected by the system vs Homebrew distinction — `uv run` always uses the venv Python. Only direct `python3` invocations in hook commands need the explicit path.

### Verifying the Python Version

```bash
# Check which python3 is in PATH (may differ between shell and subprocess)
which python3
python3 --version

# Check the Homebrew python3 explicitly
/opt/homebrew/bin/python3 --version

# Test that the hook script's syntax is valid
/opt/homebrew/bin/python3 -c "import py_compile; py_compile.compile('~/.claude/memory-compiler/hooks/session-end.py')"
```

### Context: obsidian-session-log Without transcript_path

When `obsidian-session-log` is run without a `transcript_path` (e.g., manually triggered from the terminal rather than by the SessionEnd hook), it writes only "session ended" to the daily log without an AI-generated summary. This is expected behavior — the AI summary requires the JSONL transcript file that Claude Code provides to hooks via stdin. Manual runs without that context produce a minimal log entry. This is not a bug; it's a limitation of running outside the hook context.

## Related Concepts

- [[wiki/concepts/memory-compiler-mac-migration]] — Mac migration context where this issue was discovered
- [[wiki/claude-code/hooks]] — Claude Code hook system including SessionStart, PreCompact, SessionEnd
- [[wiki/concepts/remote-server-dotfiles-bootstrap]] — server setup scripts where Python version awareness is also important

## Sources

- [[daily/2026-04-22.md]] — Mac migration to ai_leed@192.168.1.44: stop-hooks silently completed with no output; root cause was `/usr/bin/python3` = 3.9 on new Mac, hook scripts use `Path | None` syntax (requires 3.10+); fix was replacing all `python3` with `/opt/homebrew/bin/python3` in `~/.claude/settings.json`; `xlsxwriter` also installed for Homebrew Python
