Skip to content

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 run for projects on uv, which sidesteps activation entirely
  • A CLAUDE.md instruction that tells Claude to call the venv’s interpreter directly
  • A .claude/settings.json env block that pre-sets VIRTUAL_ENV and PATH for every session
  • A PreToolUse hook that blocks bare python and pip so 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:

CLAUDE.md
## 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.

CLAUDE.md
## 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:

.claude/settings.local.json
{
  "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:

.claude/hooks/enforce-venv.py
#!/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:

.claude/settings.json
{
  "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):

CLAUDE.md
## 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):

.claude/settings.json
{
  "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):

.claude/settings.local.json
{
  "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

Last updated on

Please submit corrections and feedback...