I open the same repository from an editor terminal during the day and from a separate shell at night. One evening I noticed that only the night sessions were starting on a model name that was several releases old.
The cause turned out to be me, a few months earlier. I had wanted to pin a model temporarily and wrote export ANTHROPIC_MODEL=... into ~/.zshrc. That line never came back out. Anything written into a shell profile never shows up in the interface — it is silently injected every time you open a terminal, so the only clue is a vague sense that the responses feel different.
When ANTHROPIC_DEFAULT_MODEL arrived in v2.1.236, my first thought was that switching to it would fix the problem. It did not. The two variables play different roles, and while one of them is set, the other never gets a chance to appear.
The two variables do not replace each other
ANTHROPIC_MODEL has been around for a while. While it is set, it pins the session to that model. ANTHROPIC_DEFAULT_MODEL is the new one, and it decides which model a new session starts on. If you pick a different model with /model during the session, your choice wins and it persists across restarts.
So the first is a fixed assignment and the second is a starting value you are meant to move. When both are present, the fixed one wins. Add the new variable without removing the old one and not a single session changes where it starts.
| Where the model is set | Scope | Can /model override it mid-session? | How easy is it to forget? |
|---|---|---|---|
ANTHROPIC_MODEL | Every session while it is set | It is a deliberate pin, so normally you leave it alone | Very easy — invisible when it lives in a shell profile |
ANTHROPIC_DEFAULT_MODEL | The starting model of new sessions | Yes, and the choice survives restarts | Moderate |
model in settings.json | That user or that project | Yes | Low — it is a visible file |
A model set inside the env block of settings.json | Every session in that project | Behaves like a pin | Very easy — invisible if you only read the model field |
The last two columns are where the trouble lives. Four or more places hold what looks like the same string — a model name — but they behave differently, and two of them sit somewhere you are unlikely to look. It took me several round trips between /status and my config files before I understood what was actually happening.
List everything that could be deciding your model
What I needed was not a prediction of which setting wins. It was a single screen showing what is set at all. This small script walks the environment variables, user settings, project settings, local settings, and shell profiles, and separates pins from starting values.
#!/usr/bin/env python3
"""List every setting that could decide the Claude Code execution model.
Usage:
python3 model_sources.py [project root]
With no argument, the current directory is treated as the project root.
"""
import json
import os
import re
import sys
import unicodedata
# Allow the home directory to be swapped out so the script is testable
HOME = os.environ.get("MODEL_SOURCES_HOME") or os.path.expanduser("~")
# These variables behave differently. Confusing them is how you misread restarts.
ENV_VARS = [
("ANTHROPIC_MODEL", "pin", "pins every session while it is set"),
("ANTHROPIC_DEFAULT_MODEL", "default", "starting model for new sessions; /model wins"),
("ANTHROPIC_SMALL_FAST_MODEL", "aux", "helper work only; does not change the main model"),
]
# Later entries are closer to the work (project beats user)
SETTINGS_FILES = [
("user settings", os.path.join(HOME, ".claude", "settings.json")),
("project settings", os.path.join("{root}", ".claude", "settings.json")),
("local settings", os.path.join("{root}", ".claude", "settings.local.json")),
]
# The usual places a forgotten export hides
PROFILES = [".zshrc", ".zshenv", ".bashrc", ".bash_profile", ".profile"]
JSONC_COMMENT = re.compile(r"^\s*//")
def load_jsonc(path):
"""Read JSON while tolerating // line comments. Never raise on bad input."""
try:
with open(path, encoding="utf-8") as fh:
body = "".join(l for l in fh if not JSONC_COMMENT.match(l))
return json.loads(body), None
except FileNotFoundError:
return None, None
except (json.JSONDecodeError, OSError) as exc:
return None, f"could not read: {exc}"
def collect(root):
found = []
for name, kind, note in ENV_VARS:
value = os.environ.get(name)
if value:
found.append(("environment", name, value, kind, note))
for label, template in SETTINGS_FILES:
path = template.format(root=root)
data, error = load_jsonc(path)
if error:
# Swallowing a broken config is how "I set it and nothing happened" starts
found.append((label, path, "-", "error", error))
continue
if not data:
continue
if data.get("model"):
found.append((label, path, data["model"], "config", "model field"))
nested = (data.get("env") or {}).get("ANTHROPIC_MODEL")
if nested:
found.append((label, path, nested, "pin", "env block injects ANTHROPIC_MODEL"))
for name in PROFILES:
path = os.path.join(HOME, name)
try:
with open(path, encoding="utf-8") as fh:
lines = fh.readlines()
except OSError:
continue
for lineno, line in enumerate(lines, 1):
if "ANTHROPIC_MODEL" in line or "ANTHROPIC_DEFAULT_MODEL" in line:
found.append((
"shell profile", f"{path}:{lineno}", line.strip(), "shell",
"re-injected every time you open a shell",
))
return found
def display_width(text):
"""Count wide characters as two columns so the table stays aligned."""
return sum(2 if unicodedata.east_asian_width(ch) in "WF" else 1 for ch in text)
def pad(text, width):
return text + " " * max(0, width - display_width(text))
def main():
root = sys.argv[1] if len(sys.argv) > 1 else os.getcwd()
rows = collect(root)
if not rows:
print("Nothing is setting a model (the CLI default applies)")
return 0
width = max(display_width(r[0]) for r in rows)
print(f"{pad('source', width)} kind value / location")
print("-" * 72)
for origin, where, value, kind, note in rows:
print(f"{pad(origin, width)} {kind.ljust(8)} {value}")
print(f"{' ' * width} └ {where} — {note}")
pins = {r[2] for r in rows if r[3] == "pin"}
defaults = {r[2] for r in rows if r[3] in ("default", "config")}
print()
if len(pins) > 1:
print(f"WARNING: {len(pins)} conflicting pins: {', '.join(sorted(pins))}")
if pins and defaults - pins:
print(f"WARNING: pins {sorted(pins)} make defaults {sorted(defaults - pins)} unreachable")
if not pins and len(defaults) > 1:
print(f"NOTE: {len(defaults)} candidate defaults (the closest one wins)")
return 1 if len(pins) > 1 else 0
if __name__ == "__main__":
sys.exit(main())Three choices in there are deliberate.
It reads inside the env block of settings.json, not just the model field. Checking only model and concluding "project settings are empty, so they cannot be the cause" is exactly how the hardest pin to find stays hidden.
It reports broken JSON as a row instead of swallowing the error. When a config file fails to parse, the symptom you experience is "I set it and nothing happened" — cause and symptom are far apart, so this one should never be silent.
It exits with status 1 when more than one pin is present. You do not need that if you are reading the output yourself, but setups like this tend to break right after you stop reading them.
What it printed on my machine
Here is the output against a configuration set that mirrors mine — user settings, project settings, local settings, a shell profile, and live environment variables.
source kind value / location
------------------------------------------------------------------------
environment pin claude-opus-4-1-20250805
└ ANTHROPIC_MODEL — pins every session while it is set
environment default claude-sonnet-5
└ ANTHROPIC_DEFAULT_MODEL — starting model for new sessions; /model wins
user settings config claude-sonnet-5
└ /home/me/.claude/settings.json — model field
project settings config claude-opus-5
└ /work/proj/.claude/settings.json — model field
project settings pin claude-opus-5
└ /work/proj/.claude/settings.json — env block injects ANTHROPIC_MODEL
local settings config claude-haiku-4-5-20251001
└ /work/proj/.claude/settings.local.json — model field
shell profile shell export ANTHROPIC_MODEL="claude-opus-4-1-20250805"
└ /home/me/.zshrc:2 — re-injected every time you open a shell
shell profile shell export ANTHROPIC_DEFAULT_MODEL="claude-sonnet-5"
└ /home/me/.bash_profile:1 — re-injected every time you open a shell
WARNING: 2 conflicting pins: claude-opus-4-1-20250805, claude-opus-5
WARNING: pins ['claude-opus-4-1-20250805', 'claude-opus-5'] make defaults ['claude-haiku-4-5-20251001', 'claude-sonnet-5'] unreachableRead it top down. Of those eight rows, only the first one decides which model a session actually starts on. Four of the remaining rows are settings someone wrote believing they were in effect. ANTHROPIC_DEFAULT_MODEL, the user-level model, and the local model are never consulted in this state.
Remove ANTHROPIC_MODEL from the shell profile and drop the env block from the project settings, and the same script prints this instead.
source kind value / location
------------------------------------------------------------------------
environment default claude-sonnet-5
└ ANTHROPIC_DEFAULT_MODEL — starting model for new sessions; /model wins
user settings config claude-sonnet-5
└ /home/me/.claude/settings.json — model field
project settings config claude-opus-5
└ /work/proj/.claude/settings.json — model field
local settings config claude-haiku-4-5-20251001
└ /work/proj/.claude/settings.local.json — model field
shell profile shell export ANTHROPIC_DEFAULT_MODEL="claude-sonnet-5"
└ /home/me/.bash_profile:1 — re-injected every time you open a shell
NOTE: 3 candidate defaults (the closest one wins)The warning changed from a conflict into a note about multiple candidates. In this state, whichever one is chosen can still be overridden with /model. Once the pins are gone, the configuration becomes something a human can reason about again.
One limitation worth naming: this script tells you what is set, not what ultimately wins. For that, start claude and read /status. Precedence details can shift between versions, so keeping the inventory and the measurement separate is the safer habit.
Where I ended up putting things
As an indie developer maintaining iOS and Android apps side by side, I want different models in different repositories. A project where the work is mostly reading existing code and a project where I am building a screen from scratch are not well served by the same choice. Even so, the number of places holding that choice needed to come down.
This is where it settled.
- No model settings in shell profiles at all. Removing everything that takes effect where I cannot see it was the highest priority
- One everyday model, expressed through
ANTHROPIC_DEFAULT_MODEL. It is a starting point, and/modelmoves it when the day's work calls for something else - Per-project differences go in
modelinside.claude/settings.json. It lives in a file, so it is still visible to me six months from now - When I really do need a pin, it goes on the command itself.
ANTHROPIC_MODEL=claude-haiku-4-5-20251001 claude -p "..."keeps the lifetime of the pin to a single invocation
The fourth one made the biggest difference. Pins are a legitimate tool, but the moment you extend their lifetime they turn into settings people forget. Attaching one to the command instead of a profile removed almost all of the later detective work.
For how to choose between models in the first place, Claude Code Model Selection Strategy covers the reasoning by use case. If you need a record of which model an unattended job actually ran on, Which Model Ran Last Night's Unattended Session? goes into that.
Three things to verify after you change anything
Run these three checks right after you touch the configuration and you will save yourself the confusion later.
Open a new shell and run env | grep ANTHROPIC. The shell you were editing in still holds the old values, so reopening matters. Whatever appears here is what your next session inherits.
Start claude and read the model shown by /status. If the inventory and the live session agree, your configuration is doing what you think it is doing.
Then put the script somewhere its exit code is used. I run it as a preflight step before unattended jobs and stop the job when two or more pins turn up. If you want pinning handled as a system rather than a habit, including credit ceilings, Pin Your Execution Model with enforceAvailableModels describes a more thorough design.
If you only do one thing today, run grep -rn ANTHROPIC_MODEL ~/.zshrc ~/.bashrc ~/.profile. Nothing returned means no invisible pin exists. Anything returned is what is deciding your model right now.