◉CLAUDE LABJP
●2.1.289 — Claude Code 2.1.289 (Oct 3) fixes deny/ask rules on nested parts of compound shell commands and a terminal freeze on code blocks●10/07 — 2 days left until the old spellings of Claude Desktop / Cowork managed-config keys stop being accepted. After the cutoff they fail closed●ENV — A report (#89386) says setting DISABLE_TELEMETRY to 0 does not turn it off. How the value is read as a boolean is the open question●NEW — claude-sonnet-4-5 stops on November 30 — I checked the bill before calling its replacement cheaper●MODS — Claude Code 2.1.287 introduced Claude Mods, which let plugins change deeper behavior●CONN — A request to use one Connector with different accounts (#27302) has passed 260 comments. A good case for thinking about how to separate work and personal use●2.1.289 — Claude Code 2.1.289 (Oct 3) fixes deny/ask rules on nested parts of compound shell commands and a terminal freeze on code blocks●10/07 — 2 days left until the old spellings of Claude Desktop / Cowork managed-config keys stop being accepted. After the cutoff they fail closed●ENV — A report (#89386) says setting DISABLE_TELEMETRY to 0 does not turn it off. How the value is read as a boolean is the open question●NEW — claude-sonnet-4-5 stops on November 30 — I checked the bill before calling its replacement cheaper●MODS — Claude Code 2.1.287 introduced Claude Mods, which let plugins change deeper behavior●CONN — A request to use one Connector with different accounts (#27302) has passed 260 comments. A good case for thinking about how to separate work and personal use
Articles/Claude Code
⟐ Claude Code/2026-08-21Intermediate

Adding ANTHROPIC_DEFAULT_MODEL Changes Nothing While ANTHROPIC_MODEL Is Still Set

ANTHROPIC_DEFAULT_MODEL, added in v2.1.236, is not a replacement for ANTHROPIC_MODEL. Here is how the two differ, plus a Python script that lists every place a model can be pinned.

Claude Code259environment variables4model selection4configuration10

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 setScopeCan /model override it mid-session?How easy is it to forget?
ANTHROPIC_MODELEvery session while it is setIt is a deliberate pin, so normally you leave it aloneVery easy — invisible when it lives in a shell profile
ANTHROPIC_DEFAULT_MODELThe starting model of new sessionsYes, and the choice survives restartsModerate
model in settings.jsonThat user or that projectYesLow — it is a visible file
A model set inside the env block of settings.jsonEvery session in that projectBehaves like a pinVery 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'] unreachable

Read 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.

  1. No model settings in shell profiles at all. Removing everything that takes effect where I cannot see it was the highest priority
  2. One everyday model, expressed through ANTHROPIC_DEFAULT_MODEL. It is a starting point, and /model moves it when the day's work calls for something else
  3. Per-project differences go in model inside .claude/settings.json. It lives in a file, so it is still visible to me six months from now
  4. 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.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $15 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⟐ Claude Code2026-09-25
The Week Opus 5.5 Became the Default, I Counted the Places I'd Left as Aliases
Claude Code 2.1.280 made Opus 5.5 the default Opus and moved Pro's default from Sonnet to Opus. Before touching a single setting, I checked three things: what the alias resolves to, whether a saved /effort carries over, and what $0.20 cache reads do to a monthly estimate. Scripts included.
⟐ Claude Code2026-09-20
I Was Paying for Pro and Still Getting Billed by the Console
Your subscription and your API usage are two separate ledgers, and Claude Code will happily run on either one. Which credential wins is decided by a precedence list where your login sits dead last. Here is how to read status, and why unattended runs answer differently.
⟐ Claude Code2026-09-12
My overnight crawl never came back, and the reason was that WebFetch had no ceiling on how long it would wait
Claude Code v2.1.268 gives WebFetch a 300-second deadline. Here is how an unattended run stalls on a server that never closes its response, why CLAUDE_CODE_WEBFETCH_DEADLINE_MS=0 does not mean 'do not wait', and how I now place two separate ceilings.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links