Nothing in a project can unset an inherited Claude Code env var
The settings docs say values are replaced, with permission rules as the stated exception. `env` is a second exception nobody documented: it deep-merges across all four scopes, and every way you might try to clear an inherited key either fails or leaves something worse behind.
The settings documentation is clear about how scopes combine: “When the same setting appears in multiple scopes, Claude Code applies them in priority order,” highest to lowest being managed, command line arguments, local, project, user. It gives an example in those terms, where a project value for spinnerTipsEnabled beats a user value.
It then names the exception: “Permission rules behave differently because they merge across scopes rather than override.”
There is a second exception. env merges too, and the docs never say so.
env merges across all four scopes
I set a shared key and one unique key in each of the four scopes, then read the result from inside a hook subprocess, which sees the environment Claude Code actually assembled rather than what the model believes it assembled.
| Scope | File | Shared key | Unique key |
|---|---|---|---|
| User | $CLAUDE_CONFIG_DIR/settings.json |
USER |
U_ONLY=u |
| Project | .claude/settings.json |
PROJECT |
P_ONLY=p |
| Local | .claude/settings.local.json |
LOCAL |
L_ONLY=l |
| CLI | --settings |
CLI |
C_ONLY=c |
Result, identical across two runs:
SHARED=[CLI]
U_ONLY=[u]
P_ONLY=[p]
L_ONLY=[l]
C_ONLY=[c]
The shared key resolves by documented precedence, so that part behaves. But all four unique keys survive. A project defining its own env block does not replace the user’s env block, it adds to it. Read the documented precedence rule literally and you would expect U_ONLY and P_ONLY to be gone.
An inherited key cannot be cleared
This matters because the merge runs one way. If a key is set in a lower-precedence scope, a higher-precedence scope has no way to take it away. I put CLAUDE_CODE_ENABLE_TELEMETRY=1 in the user scope and tried four ways to get rid of it from the project.
| Attempt in project scope | Result in subprocess |
|---|---|
Define an env block that omits the key |
1 |
"env": {} |
1 |
"CLAUDE_CODE_ENABLE_TELEMETRY": "" |
empty string |
"CLAUDE_CODE_ENABLE_TELEMETRY": null |
the four-character string null |
The first two do nothing, which follows from the merge. The third is the documented mechanism, and the docs are straight about its limit: setting a variable to "" makes Claude Code treat it as unset “for provider selection,” while “subprocesses still inherit the empty value.” So it is an override to empty, not a removal.
The fourth is the one to avoid. Writing null, the obvious thing to reach for when you want a key gone, produces the literal string null in the environment. A subprocess doing if [ -n "$VAR" ] sees a non-empty value. Anything treating the variable as a URL or a path gets null.
Non-string values are silently coerced
null is not special-cased, it is one instance of a general behaviour. Every JSON value in env goes through JavaScript string conversion:
| JSON in settings | Value in the environment |
|---|---|
null |
null |
42 |
42 |
true |
true |
[1,2] |
1,2 |
{"a":1} |
[object Object] |
"" |
(empty) |
[object Object] is the tell. There is no validation and no warning: the session starts normally, and claude doctor against the same directory reports “No installation issues found.”
Numbers and booleans coerce to what you would want anyway. The risk is null, and objects or arrays written by a config generator.
OTEL_ variables never reach subprocesses
While measuring the above I lost two variables that should have been there. The docs describe env as “applied to every session and to subprocesses Claude Code spawns from it,” and the example given for the setting is:
"env": {
"CLAUDE_CODE_ENABLE_TELEMETRY": "1",
"OTEL_METRICS_EXPORTER": "otlp",
"OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf"
}
Set exactly that, and only the first key arrives. Both OTEL_ keys are absent from the subprocess environment.
It is an exact prefix match on OTEL_, and it is not about where the value came from:
| Variable | Set via settings env |
Exported in the parent shell |
|---|---|---|
OTEL_METRICS_EXPORTER |
absent | absent |
OTEL_FOO |
absent | absent |
OTELX_FOO |
present | present |
MY_OTEL_FOO |
present | present |
PLAIN_KEY |
present | present |
The same holds for Bash tool subprocesses, not just hooks, and on both authenticated and unauthenticated runs. Everything that is not OTEL_-prefixed passes through untouched.
What this does not show is that telemetry is broken. The likely reading is that Claude Code consumes its OTEL configuration itself and deliberately declines to hand it down, which would stop every child process from standing up its own exporter. I did not run a collector, so I am not claiming anything about whether telemetry export works. The measured claim is narrow: if a hook or a Bash command of yours expects to read OTEL_* out of the environment, it will not find it, and the documented sentence about subprocesses does not hold for that prefix.
–settings layers, it does not replace
The flag is documented only as “Path to a settings JSON file or a JSON string to load additional settings from,” with no statement about precedence. Measured, it behaves as the command-line-argument tier the precedence list describes: it beat local, project and user for the shared key, while every lower scope’s unique keys survived. Both forms work, a file path and inline JSON. It adds a layer, it does not swap one in.
The footnote: hooks
Hooks merge across scopes as well, and all four SessionStart hooks fired in every run, including the one passed through --settings. That one is documented, in the hooks page rather than the settings page: “Hook entries merge across settings levels rather than replacing each other.” Same page is equally clear that there is no way to disable an individual inherited hook.
So the pattern is consistent across env, hooks and permissions: lower scopes accumulate, higher scopes cannot subtract. It is only env where that is left unstated.
What I did not test
The managed scope, /Library/Application Support/ClaudeCode/managed-settings.json, is untested here. Writing it is a machine-wide change, and I was not willing to make one to measure a merge. Everything above covers user, project, local and --settings only. The docs do describe array concatenation and object deep-merging for the managed drop-in directory specifically, which is what makes the silence about the other four scopes odd, since env demonstrably deep-merges among them too.
Practical upshot
Treat env as additive and one-way. A project cannot sandbox itself from a user-level variable by redefining env, and if you need a lower-scope variable gone, remove it at the scope that sets it rather than trying to override it from above.
Never write null in env. It does not clear the key, it sets it to the string null, and nothing warns you.
Measured against Claude Code 2.1.224, commit 8a2a469b68f9, on macOS. Merged environments were read from a SessionStart hook subprocess and cross-checked through the Bash tool. Every result above reproduced across at least two runs, and the merge and coercion results were confirmed identically on both authenticated and unauthenticated sessions.