{"slug": "claude-code-env-vars-don-t-persist-3-of-80-runs-fell-back-to-prod", "title": "Claude Code Env Vars Don't Persist: 3 of 80 Runs Fell Back to Prod", "summary": "A developer ran 97 headless Claude Code sessions to measure how shell state carries between Bash tool calls, finding that environment variables, virtualenv activations, functions, aliases and umask settings all reset because each call starts a new shell, while only the working directory persists. In 80 runs of ordinary tasks on Sonnet 5.5, the model ended up with the wrong environment 3 times, all involving variables with silent defaults, and the developer recommends using the env key in settings.json or a SessionStart hook writing to $CLAUDE_ENV_FILE.", "body_md": "I told Claude Code `export APP_ENV=staging`. My next message said to run the script. It printed `target = production`.\n\nNothing got deployed. `target.py` is a one-line stub in a throwaway repo, built around `os.environ.get(\"APP_ENV\", \"production\")`, a default you've probably seen in real code. But Claude Code env vars don't persist between Bash tool calls, and I wanted to know how often that actually lands. So I ran 97 headless sessions and counted.\n\n`export`, `source .venv/bin/activate`, functions, aliases and `umask` all vanish before the next call.`cd` is the exception, and only when the command succeeds and stays inside the project. In subagents it didn't carry at all.`env` in `settings.json` for plain values, a `SessionStart` hook writing to `$CLAUDE_ENV_FILE` for a virtualenv.\nClaude Code env vars don't persist because each Bash tool call starts a new shell, so anything held in shell memory dies with that call. Only the working directory is tracked across calls.\n\nI set eight kinds of state in one call and read them back in the next. All 3 runs gave identical results.\n\n| Set in call 1 | Seen in call 2 | \n|---|---|\n| `export LAB_EXPORT=1` | empty | \n| `LAB_PLAIN=2` | empty | \n| `source .venv/bin/activate` | `VIRTUAL_ENV` empty, no`python` on PATH | \n| `lab_fn() { ... }` | not found | \n| `alias lab_alias=...` | not found | \n| `umask 077` | back to `022` | \n| `set -o noclobber` | off | \n| `cd sub` | kept | \n\nYou can see it for yourself with one command:\n\n```\nclaude -p 'Make two separate Bash calls, one at a time: first \"export FOO=1\", then \"echo FOO=$FOO\". Show me the second output verbatim.' --allowedTools Bash\n```\n\nIt comes back `FOO=` with nothing after the equals sign.\n\nThe trap is that row at the bottom. `cd` working makes the tool feel like one long terminal session, so you assume the rest of your shell came along too.\n\n`cd` persists in the main session when the command succeeds and ends inside the project. Break either condition and the shell stays where it was.\n\n| Command | Next `pwd` | \n|---|---|\n| `cd sub` | `proj/sub` | \n| `cd /tmp` (outside the project) | back at `proj` , with the note`Shell cwd was reset to ...` | \n| `cd sub && false` (exit 1) | still `proj` | \n| `cd sub` inside a subagent, then`pwd` | still `proj` (2 of 2 runs) | \n\nThe subagent row is the sneaky one. `cd sub` prints nothing and exits 0, then the next call is back at the root. A relative path written after that lands somewhere you didn't plan. Subagents do start in the main session's current directory, though: after `cd sub` in the parent, a subagent's first `pwd` was `proj/sub` in 2 of 2 runs.\n\nRarely. Across 80 runs of ordinary-sounding tasks on Sonnet 5.5, Claude ended up with the wrong environment 3 times, and all 3 involved a variable with a silent default.\n\n| Task (10 runs each) | Correct | Wrong | \n|---|---|---|\n| Export, then run the script | 10 | 0 | \n| Export, list files, read script, run it | 10 | 0 | \n| Export, then a subagent runs the script | 10 | 0 | \n| Export, 8 unrelated Bash calls, then run | 9 | 1 | \n| Export in one message, run in the next | 8 | 2 | \n| Activate venv, then run | 10 | 0 | \n| Activate venv, then a subagent runs it | 10 | 0 | \n| Activate venv in one message, run in the next | 10 | 0 | \n\nThe model knows this rule. It wrote `export APP_ENV=staging && python3 target.py`, re-exported in each command, and briefed subagents with the variable spelled out in their prompt. That last part surprised me. I expected delegation to be the weak spot, and it scored 20 of 20.\n\nThe misses came from two places:\n\n`export APP_ENV=staging` as its own call, did eight other things, then ran a bare `python3 target.py`.` APP_ENV=staging` on its own. Twice it ran the bare command.\nThat second miss is partly on me, and I think it's the realistic one. You skim the reply, type the next instruction, and move on.\n\nAll 3 misses were reported in the final reply. \"It was not set when `target.py` ran.\" That sentence is useful after a read-only script. After `terraform apply` it's a postmortem.\n\nTen runs per pattern is too small to quote as a failure rate. The finding I'd trust is the split: 3 misses in 50 variable runs, 0 in 30 venv runs. A missing dependency throws `ModuleNotFoundError`. A missing variable with a default throws nothing.\n\nSet them before the session starts, since nothing Claude runs mid-session survives the call. I tested four ways.\n\n| Method | `APP_ENV` | venv | Subagent | \n|---|---|---|---|\n| `env` in`.claude/settings.json` | works | **no** | not tested | \n| Activate, then launch `claude` from that shell | works | works | not tested | \n| `SessionStart` hook writing to`$CLAUDE_ENV_FILE` | works | works | works | \n| `CLAUDE_ENV_FILE=./env.sh claude` at launch | works | works | `APP_ENV` works | \n\n**Plain values:** use `env` in settings. It's the smallest fix.\n\n```\n{ \"env\": { \"APP_ENV\": \"staging\" } }\n```\n\nIt can't run anything, though. With this alone, `python3` still resolved to `/usr/bin/python3` and `check.py` died with `ModuleNotFoundError`.\n\n**Virtualenvs and anything that needs a script:** use a hook. Whatever it appends to `$CLAUDE_ENV_FILE` applies to every later Bash call, subagents included.\n\n```\n{\n  \"hooks\": {\n    \"SessionStart\": [{\n      \"hooks\": [{\n        \"type\": \"command\",\n        \"command\": \"printf 'source \\\"%s/.venv/bin/activate\\\"\\\\nexport APP_ENV=staging\\\\n' \\\"$CLAUDE_PROJECT_DIR\\\" >> \\\"$CLAUDE_ENV_FILE\\\"\"\n      }]\n    }]\n  }\n}\n```\n\nUse `>>`, so you don't wipe what another hook wrote. Put per-developer values in `.claude/settings.local.json` and keep secrets out of anything committed.\n\nNo, not in anything I tried. The env file behaves as a snapshot taken at session start.\n\n`export APP_ENV=edited-midsession`, confirmed it with `cat`, and the next call still said `staging`. Same result in 2 of 2 runs.\nIf you need to switch from staging to prod, restart the session. For a single command, ask for the inline form: `APP_ENV=prod python3 target.py`.\n\nNo, and this is the cheapest fix in the post. All 3 misses needed a fallback to go wrong quietly.\n\n```\n# silently picks prod when the variable is missing\ntarget = os.environ.get(\"APP_ENV\", \"production\")\n\n# stops right here with a KeyError\ntarget = os.environ[\"APP_ENV\"]\n```\n\nThe second line turns every miss in that table into a crash before any work happens. Default to the safe environment if you must have a default. Anything an agent can run should fail loudly on missing config.\n\nClaude Code 2.1.318 on macOS with zsh, headless `claude -p` with Sonnet 5.5, project settings only, 97 sessions at about $2.08 of list-price usage. I didn't measure interactive sessions, other models or Linux, so run the one-liner above on your own setup first.\n\nNo. Claude Code env vars set with `export` in one Bash tool call are gone by the next, along with activated virtualenvs, functions, aliases and `umask`, because every call starts a fresh shell. Only `cd` carries over, and only in the main session on a successful command that stays inside the project. Claude works around this most of the time (77 of 80 runs here), but for anything that matters, define the state before launch with `env` in `.claude/settings.json` or a `SessionStart` hook that appends to `$CLAUDE_ENV_FILE`, and remove production defaults from your scripts.\n\n*Written by the developer behind [Preterview](https://preterview.com/en), an interview prep platform.*", "url": "https://wpnews.pro/news/claude-code-env-vars-don-t-persist-3-of-80-runs-fell-back-to-prod", "canonical_source": "https://dev.to/ji_ai/claude-code-env-vars-dont-persist-3-of-80-runs-fell-back-to-prod-p85", "published_at": "2026-11-05 00:43:59+00:00", "updated_at": "2026-11-05 00:52:29.024936+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools"], "entities": ["Claude Code", "Anthropic", "Sonnet 5.5"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/claude-code-env-vars-don-t-persist-3-of-80-runs-fell-back-to-prod", "markdown": "https://wpnews.pro/news/claude-code-env-vars-don-t-persist-3-of-80-runs-fell-back-to-prod.md", "text": "https://wpnews.pro/news/claude-code-env-vars-don-t-persist-3-of-80-runs-fell-back-to-prod.txt", "jsonld": "https://wpnews.pro/news/claude-code-env-vars-don-t-persist-3-of-80-runs-fell-back-to-prod.jsonld"}}