Your cache_read_input_tokens is zero. Here is the list of things that silently did it.
You added cache_control, the docs said up to 90% cheaper, and the counter still reads zero on every request. Nothing errored. There is no warning. The cache just never gets read.
This is almost always one of a small number of specific mistakes, and they are all instances of a single rule.
The one rule
Prompt caching is a prefix match. The cache key is the exact bytes of the rendered prompt up to each breakpoint. Any byte that changes anywhere in the prefix invalidates the cache for every breakpoint at or after that position.
Render order is:
tools -> system -> messages
That ordering is the whole game. tools render at position zero, so a change there invalidates everything. A breakpoint on the last system block caches tools and system together.
Once you internalise "prefix match, tools first", every item below is predictable rather than surprising.
The audit list
Grep your prompt-assembly path for these. In my experience the first one accounts for most zero-hit reports.
| In the prefix | Why the cache never hits |
|---|---|
datetime.now() / Date.now()
|
Prefix differs on every request |
uuid4() / a request ID |
Same, and unfixable by retrying |
json.dumps(d) without sort_keys=True
|
Key order is not guaranteed, so the bytes move |
Iterating a set to build content |
Iteration order is not stable |
| Session or user ID in the system prompt | Per-user prefix, zero sharing across users |
if flag: system += ... |
Every flag combination is a separate prefix |
tools=build_tools(user) |
Tools render first, so a per-user tool set caches for nobody |
The fix in every case is the same shape: make it deterministic, move it after the last breakpoint, or delete it if it is not load bearing.
A "current date" line in the system prompt is the classic. It feels harmless because it is one short line. It sits in front of everything.
What does not invalidate everything
This is the part people over-correct on. There are three cache tiers, and a change only invalidates its own tier and below:
| Change | Tools | System | Messages |
|---|---|---|---|
| Tool definitions added, removed, reordered | gone | gone | gone |
| Model switch | gone | gone | gone |
| Web search or citations toggle | kept | gone | gone |
| System prompt content | kept | gone | gone |
tool_choice, images, thinking on/off |
kept | kept | gone |
| Message content | kept | kept | gone |
So you can flip tool_choice per request, or toggle thinking, without losing the tools and system cache. Only tool-definition changes and model switches force a full rebuild.
Two of those rows have an escape hatch, and they are not gated together:
-
System prompt content. Instead of editing top-level
systemmid-conversation, append a{"role": "system", "content": "..."}message tomessages. It lands after the cached history, so the prefix survives. Available today on Claude Opus 5, Opus 4.8, Fable 5 and Mythos 5, with no beta header. Unsupported models return a 400, so catch it and fall back. -
Tool definitions. On Claude Opus 5 there is a beta (
mid-conversation-tool-changes-2026-07-01) that adds and removes tools between turns viatool_addition/tool_removalblocks, without invalidating the prefix. Tools you plan to add must already be declared with"defer_loading": true.
Model switching has no escape hatch. Caches are model scoped. If you want a cheaper model for a sub-task, spawn a subagent rather than switching the main loop.
The minimum is not monotonic
There is a minimum cacheable prefix. Below it, nothing caches, you get no error, and cache_creation_input_tokens is just zero.
The trap is that newer does not mean lower:
| Model | Minimum |
|---|---|
| Opus 5, Fable 5, Mythos 5 | 512 |
| Opus 4.8, Sonnet 5, Sonnet 4.6, Sonnet 4.5 | 1024 |
| Opus 4.7 | 2048 |
| Opus 4.6, Opus 4.5, Haiku 4.5 | 4096 |
A 3K-token prompt caches on Opus 5 and Opus 4.8 and silently does not on Opus 4.6 or Haiku 4.5. If you wrote a prompt off as "too short to cache" on an older model, it may be worth re-checking.
Two timing gotchas
The 20-block lookback. Each breakpoint walks backward at most 20 content blocks looking for a prior cache entry. Agentic loops blow through that easily, because one turn can add a dozen tool_use and tool_result pairs. If a single turn adds more than 20 blocks, the next request's breakpoint does not find the previous cache and silently misses. Fix: place an intermediate breakpoint every ~15 blocks in long turns.
Concurrent requests all miss. A cache entry becomes readable only once the first response begins streaming. Fire N identical-prefix requests in parallel and all N pay full price, because none of them can read what the others are still writing. For fan-out, send one, await the first streamed token, then fire the rest.
Reading the counters honestly
Three fields, and the third one misleads people:
-
cache_creation_input_tokens: written to cache this request, billed at a premium -
cache_read_input_tokens: served from cache, billed at roughly a tenth -
input_tokens: the uncached remainder only
Total prompt size is the sum of all three. If your agent ran for an hour and input_tokens shows 4K, the rest was served from cache. Check the sum, not the single field, before concluding anything about prompt size.
Is it even worth it
Reads cost about 0.1x. Writes cost 1.25x on the 5 minute TTL and 2x on the 1 hour TTL.
So break-even depends on which TTL you picked. With 5 minutes, two requests pay for it (1.25 + 0.1 against 2 uncached). With 1 hour you need at least three (2 + 0.2 against 3). The 1 hour TTL keeps entries alive across gaps in bursty traffic, but the doubled write means it needs more reads to come out ahead.
And if the first 1K tokens of your prompt genuinely differ every request, do not cache. You will pay the write premium for zero reads. Leaving it off is the correct answer.
Pre-warming, and when not to
You can pay the cache write up front with a max_tokens: 0 request. Prefill runs, the cache is written, and you get back content: [] with stop_reason: "max_tokens" and zero output tokens billed.
Put the breakpoint on the last block shared with the real request, usually the system prompt or the tool definitions. Not on the placeholder user message, and not via top-level automatic caching, which would key the cache to the placeholder.
It is worth doing when first-request latency is user visible, the shared prefix is big enough that a cold write is noticeably slow, and there is a moment before traffic to fire it: app startup, worker boot, post-deploy.
It is not worth doing when traffic is continuous. If real requests arrive more often than the TTL, they keep the cache warm on their own and a separate warm call is a pure extra write. Scheduled re-warms only make sense when your traffic has gaps longer than the TTL.
Note that max_tokens: 0 is rejected alongside stream: true, thinking.type: "enabled", output_config.format, a forced tool_choice, or inside a Batches request.
The debugging loop
If the counter is zero, do this rather than adding more markers:
- Capture the fully rendered request body on two consecutive calls.
- Diff them.
- The first differing byte is your answer.
That diff is faster than reasoning about it, and it turns "caching is not working" into a one-line fix nearly every time.
Written with AI assistance and human review. Behaviour described reflects the Claude API as documented in July 2026; caching internals move, so verify against current docs before you rely on a specific number.
Top comments (0)