# How to configure Claude Code to use virtual environments


[Claude Code](https://code.claude.com/) 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](https://pydevtools.com/handbook/explanation/what-is-a-virtual-environment.md). Four configuration patterns work around the stateless-shell limitation:

- `uv run` for projects on [uv](https://pydevtools.com/handbook/reference/uv.md), 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

## Why `source activate` doesn't persist

Each Bash tool call spawns a separate shell process. Environment changes from `source .venv/bin/activate` live only inside that shell and vanish when the next tool call starts a new one. Anthropic confirmed this is intentional in [issue #8855](https://github.com/anthropics/claude-code/issues/8855). The rest of this guide works around it.

## 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](https://pydevtools.com/handbook/reference/uv.md) project | [`uv run`](#skip-activation-with-uv-run) |
| Tell Claude which interpreter to invoke for any venv-based project | [CLAUDE.md instructions](#point-claude-at-the-venvs-interpreter-in-claudemd) |
| Pre-set `VIRTUAL_ENV` and `PATH` so every spawned shell already sees the venv | [`.claude/settings.json` `env`](#inject-virtual_env-and-path-through-claudesettingsjson) |
| Block bare `python` and `pip` so Claude can't bypass the rules | [PreToolUse hook](#block-bare-python-and-pip-with-a-pretooluse-hook) |

## Skip activation with `uv run`

[uv](https://pydevtools.com/handbook/reference/uv.md) resolves the project's virtual environment per command, so `uv run pytest` works the same whether or not the venv is "active."

A minimal `CLAUDE.md` for a uv project:

```markdown {filename="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](https://pydevtools.com/handbook/how-to/how-to-configure-claude-code-to-use-uv.md) covers the full uv-specific configuration. The rest of this guide assumes a project that does not use uv (plain [`venv`](https://pydevtools.com/handbook/reference/venv.md), [virtualenv](https://pydevtools.com/handbook/reference/virtualenv.md), [Poetry](https://pydevtools.com/handbook/reference/poetry.md), or [conda](https://pydevtools.com/handbook/reference/conda.md)), 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.

```markdown {filename="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.
```
```markdown {filename="CLAUDE.md"}
## Python environment

This project's virtual environment lives at `.venv\`. Invoke its
interpreter directly; do not run `.venv\Scripts\activate` (it cannot
persist between Claude's tool calls).

Use these commands:

- Run a script: `.venv\Scripts\python.exe script.py`
- Open a REPL: `.venv\Scripts\python.exe`
- Install a package: `.venv\Scripts\pip.exe install <package>`
- Run a tool: `.venv\Scripts\pytest.exe`, `.venv\Scripts\ruff.exe check`,
  `.venv\Scripts\mypy.exe`

Each entry under `.venv\Scripts\` resolves to the venv's copy of the
tool, so Python sees the project's `site-packages` automatically.
```
Direct interpreter invocation is what activation approximates in the first place.

> [!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](https://code.claude.com/docs/en/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:

```json {filename=".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:

```python {filename=".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`:

```json {filename=".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:

```bash
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:

```console
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](https://pydevtools.com/handbook/how-to/how-to-write-claude-code-hooks-for-python-projects.md) for the broader hook reference.

## Combine all three layers

A non-uv project that wants full coverage uses three files together: the [CLAUDE.md instruction](#point-claude-at-the-venvs-interpreter-in-claudemd) tells Claude what to do, the [`.claude/settings.json` hook](#block-bare-python-and-pip-with-a-pretooluse-hook) catches commands Claude still gets wrong, and the [`.claude/settings.local.json` env block](#inject-virtual_env-and-path-through-claudesettingsjson) exposes `VIRTUAL_ENV` to tools that read it directly. Commit everything except `.claude/settings.local.json`, which stays per-machine.

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.

## Learn More

* [How to configure Claude Code to use uv](https://pydevtools.com/handbook/how-to/how-to-configure-claude-code-to-use-uv.md) for the cleanest path on uv projects
* [How to write Claude Code hooks for Python projects](https://pydevtools.com/handbook/how-to/how-to-write-claude-code-hooks-for-python-projects.md) for hooks beyond venv enforcement
* [How to Set Up CLAUDE.md for a Python Project](https://pydevtools.com/handbook/how-to/how-to-use-the-pydevtools-claude-md-template.md) for a starter that already covers uv, Ruff, and pytest
* [How to create and use a Python virtual environment with venv](https://pydevtools.com/handbook/how-to/how-to-create-and-use-a-python-virtual-environment-with-venv.md)
* [What is a virtual environment?](https://pydevtools.com/handbook/explanation/what-is-a-virtual-environment.md)
* [Claude Code memory documentation](https://code.claude.com/docs/en/memory)
* [Claude Code settings reference](https://code.claude.com/docs/en/settings)
* [Claude Code hooks reference](https://code.claude.com/docs/en/hooks)
