How to configure Claude Code to use virtual environments
Claude Code starts a fresh shell for every Bash tool call, so source .venv/bin/activate in one command has no effect on the next. Without configuration, Claude reaches for system Python and installs packages outside the project’s virtual environment. Four configuration patterns work around the stateless-shell limitation:
uv runfor projects on uv, which sidesteps activation entirely- A CLAUDE.md instruction that tells Claude to call the venv’s interpreter directly
- A
.claude/settings.jsonenvblock that pre-setsVIRTUAL_ENVandPATHfor every session - A PreToolUse hook that blocks bare
pythonandpipso the rules are enforced, not just suggested
Trace why source activate doesn’t persist
Each Bash tool call Claude Code runs spawns a separate shell process. Environment changes made by source .venv/bin/activate (it sets VIRTUAL_ENV and prepends .venv/bin to PATH) live only inside that shell. When the next tool call starts a new shell, those variables are gone.
A direct demonstration:
$ python3 -m venv .venv
$ bash -c 'source .venv/bin/activate && which python'
/path/to/project/.venv/bin/python
$ bash -c 'which python || echo "no python in fresh shell"'
no python in fresh shell
The first subshell sees python on the activated PATH. The second subshell, started without source, has no project-local python at all. Claude Code’s tool calls behave like the second case.
Anthropic confirmed this behavior is intentional in issue #8855, which closed a request for persistent shell sessions as “not planned.” The solutions in this guide work around it instead of relying on activation.
Pick the right configuration layer
Each layer answers a different question. Pick the ones that match what the project actually needs.
| Goal | Layer |
|---|---|
| Skip activation entirely on a uv project | uv run |
| Tell Claude which interpreter to invoke for any venv-based project | CLAUDE.md instructions |
Pre-set VIRTUAL_ENV and PATH so every spawned shell already sees the venv |
.claude/settings.json env |
Block bare python and pip so Claude can’t bypass the rules |
PreToolUse hook |
Skip activation with uv run
The simplest option is to stop relying on activation. uv resolves the project’s virtual environment per command, so uv run pytest works the same whether or not the venv is “active.” Because Claude Code’s stateless shells make activation impossible, uv run fits the model.
A minimal CLAUDE.md for a uv project:
## Python environment
This project uses uv. Run every Python command through `uv run`:
- Run a script: `uv run script.py`
- Run a tool: `uv run pytest`, `uv run ruff check`, `uv run mypy`
- Open a REPL: `uv run python`
- Add a package: `uv add <package>`
Do not call bare `python`, `python3`, or `pip`. They resolve outside the
project's virtual environment.How to configure Claude Code to use uv covers the full uv-specific configuration. The rest of this guide assumes a project that does not use uv (plain venv, virtualenv, Poetry, or conda), or one that uses uv but still has tools or scripts that need a specific interpreter.
Point Claude at the venv’s interpreter in CLAUDE.md
The portable fix is to tell Claude to call the venv’s interpreter directly. The interpreter path embeds the venv reference, so no activation is needed and no environment variables have to travel between commands.
Add this to CLAUDE.md. The exact path differs between macOS/Linux (.venv/bin/) and Windows (.venv\Scripts\); write the rule for whichever platform the team uses.
## Python environment
This project's virtual environment lives at `.venv/`. Invoke its
interpreter directly; do not run `source .venv/bin/activate` (it cannot
persist between Claude's tool calls).
Use these commands:
- Run a script: `.venv/bin/python script.py`
- Open a REPL: `.venv/bin/python`
- Install a package: `.venv/bin/pip install <package>`
- Run a tool: `.venv/bin/pytest`, `.venv/bin/ruff check`,
`.venv/bin/mypy`
Each entry under `.venv/bin/` resolves to the venv's copy of the tool,
so Python sees the project's `site-packages` automatically.This works because direct interpreter invocation is what activation is approximating in the first place. python -m site from inside .venv/bin/python reports .venv as sys.prefix regardless of VIRTUAL_ENV.
Tip
If pyproject.toml already declares a [project] section, run /init once and Claude will pick up the project layout. Then add the rule above so the .venv/bin/ path is explicit.
Inject VIRTUAL_ENV and PATH through .claude/settings.json
When tools that read VIRTUAL_ENV directly need to see an “activated” environment, set the variables in Claude Code’s env settings. Each session inherits the values when it starts, and every shell Claude spawns sees them.
The settings docs don’t document $CLAUDE_PROJECT_DIR or ~ expansion inside env values, so use absolute paths. Put the file in .claude/settings.local.json (gitignored) instead of .claude/settings.json (committed) so personal absolute paths don’t end up in source control:
{
"env": {
"VIRTUAL_ENV": "/Users/you/projects/myapp/.venv",
"PATH": "/Users/you/projects/myapp/.venv/bin:/usr/local/bin:/usr/bin:/bin"
}
}Add .claude/settings.local.json to .gitignore so it stays per-machine. The PATH prefix needs to come first; everything else (Homebrew, system tools) follows.
Verify the variables propagate by asking Claude to run python -c "import sys; print(sys.prefix)". If the output ends in .venv, the env block is wired up correctly.
Caution
Do not commit absolute paths to .claude/settings.json. They break the moment another developer clones the repo or you move the project. The CLAUDE.md instruction in the previous section is what travels with the repository; this env block is per-machine.
Block bare python and pip with a PreToolUse hook
Even with CLAUDE.md and an env block, Claude sometimes types python script.py out of habit. A PreToolUse hook intercepts the Bash tool call before it runs, blocks the command, and tells Claude exactly what to use instead.
Create .claude/hooks/enforce-venv.py in the project:
#!/usr/bin/env python3
import json
import os
import sys
data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")
project_dir = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
venv_python = os.path.join(project_dir, ".venv", "bin", "python")
venv_pip = os.path.join(project_dir, ".venv", "bin", "pip")
bare_starts = ("python ", "python3 ", "pip ", "pip3 ")
if command.startswith(bare_starts):
tool, _, rest = command.partition(" ")
target = venv_pip if tool.startswith("pip") else venv_python
print(
f"Blocked: bare {tool!r} resolves outside the project's .venv. "
f"Use {target!r} {rest}".rstrip(),
file=sys.stderr,
)
sys.exit(2)Register it in .claude/settings.json under PreToolUse:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/enforce-venv.py"
}
]
}
]
}
}$CLAUDE_PROJECT_DIR is set by Claude Code itself before it executes the hook, so the path resolves correctly even though the env block in the previous section can’t reference it.
Test the hook before relying on it:
echo '{"tool_name":"Bash","tool_input":{"command":"python script.py"}}' \
| python3 .claude/hooks/enforce-venv.py
echo $? # Should print 2 (blocked)
echo '{"tool_name":"Bash","tool_input":{"command":".venv/bin/python script.py"}}' \
| python3 .claude/hooks/enforce-venv.py
echo $? # Should print 0 (allowed)When Claude tries pip install requests, the hook prints:
Blocked: bare 'pip' resolves outside the project's .venv. Use '/path/to/project/.venv/bin/pip' install requests
Claude reads the message and retries with the correct path. See How to write Claude Code hooks for Python projects for the broader hook reference.
Combine all three layers
A non-uv project that wants every layer ends up with three small files. CLAUDE.md tells Claude what to do, and the hook catches commands Claude still gets wrong. The local env block exposes VIRTUAL_ENV to tools that read it directly.
CLAUDE.md (project root, committed):
## Python environment
This project's virtual environment lives at `.venv/`. Invoke its
interpreter directly; do not run `source .venv/bin/activate`.
- Run a script: `.venv/bin/python script.py`
- Run a tool: `.venv/bin/pytest`, `.venv/bin/ruff check`
- Install a package: `.venv/bin/pip install <package>`.claude/settings.json (project root, committed):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/enforce-venv.py"
}
]
}
]
}
}.claude/settings.local.json (project root, gitignored, per machine):
{
"env": {
"VIRTUAL_ENV": "/Users/you/projects/myapp/.venv",
"PATH": "/Users/you/projects/myapp/.venv/bin:/usr/local/bin:/usr/bin:/bin"
}
}Plus the .claude/hooks/enforce-venv.py script from the section above. Commit everything except .claude/settings.local.json, which stays per-machine so each developer’s absolute path doesn’t leak into source control.
Important
The CLAUDE.md instruction and the hook are not redundant. CLAUDE.md teaches Claude the right pattern; the hook catches the times Claude reverts to bare python despite the instruction. Keep both: one is documentation, the other is enforcement.
Learn More
- How to configure Claude Code to use uv for the cleanest path on uv projects
- How to write Claude Code hooks for Python projects for hooks beyond venv enforcement
- How to use the pydevtools CLAUDE.md template for a starter that already covers uv, Ruff, and pytest
- How to create and use a Python virtual environment with venv
- What is a virtual environment?
- Claude Code memory documentation
- Claude Code settings reference
- Claude Code hooks reference