A support agent I reviewed ran 40-turn conversations against Claude Opus 4.8 with a 12,000-token system prompt and eight tool definitions. Every request carried cache_control. The team had "enabled prompt caching" three months earlier and moved on.
cache_read_input_tokens was 0 on every single request. Every turn re-billed the full 12K prefix at full price, plus a 1.25x cache-write premium on top. They were paying roughly 25% more than if they had never touched caching at all.
The cause was a single line: f"Current date: {datetime.now():%Y-%m-%d %H:%M}" in the system prompt header. Claude prompt caching is a byte-exact prefix match, and that timestamp sat at byte 200 of a 12,000-token prefix.
TL;DR
-
Claude prompt caching is a byte-exact prefix match. Render order is
tools→system→messages. Any byte change at position N invalidates every cache breakpoint at position ≥ N. A timestamp in your system prompt makes the whole request uncacheable. -
Invalidation is tiered, not all-or-nothing. Changing
tool_choice, togglingthinking, or adding an image preserves the tools+system cache. Only tool-definition edits and model switches force a full rebuild. - Breakpoints look back at most 20 content blocks. An agentic turn that appends more than 20 tool_use/tool_result blocks silently misses the previous turn's cache. Place an intermediate breakpoint roughly every 15 blocks.
-
The minimum cacheable prefix is model-dependent and not monotonic — 512 tokens on Claude Opus 5, but 4096 on Opus 4.6 and Haiku 4.5. Below it you get no error, just
cache_creation_input_tokens: 0. - Reads cost ~0.1x base input; writes cost 1.25x (5-minute TTL) or 2x (1-hour TTL). Five-minute TTL breaks even at two requests; one-hour needs three.
Why is cache_read_input_tokens zero?
Because something in your prefix changed between requests, and the cache key is derived from the exact rendered bytes up to each breakpoint.
The API renders your request in a fixed order: tools, then system, then messages. A cache_control marker on the last system block therefore caches tools and system together. It does not cache them independently — there is one linear byte stream, and the breakpoint is a position in it.
This makes the design question simple: does your prompt-building code emit stable content strictly before volatile content? Everything else is detail.
Grep your prompt assembly path for these. Each one silently kills caching:
| Pattern | Why it breaks |
|---|---|
datetime.now() / Date.now() in system prompt |
Prefix differs every request |
uuid4() or request IDs early in content |
Every request is a unique prefix |
json.dumps(d) without sort_keys=True
|
Non-deterministic key order → different bytes |
Iterating a set to build tool descriptions |
Non-deterministic ordering |
tools=build_tools(user) varying per user |
Tools render at position 0; nothing caches across users |
if flag: system += "..." |
Each flag combination is a distinct prefix |
The fix is always the same shape: make it deterministic, move it after the last breakpoint, or delete it.
import json
def build_request(user_msg, session_ctx, tools, history):
return {
"model": "claude-opus-5",
"max_tokens": 16000,
# Position 0. Sorted, frozen, identical across every user and session.
"tools": sorted(tools, key=lambda t: t["name"]),
"system": [
{
"type": "text",
# No f-strings. No dates. No user IDs. Byte-identical forever.
"text": FROZEN_SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"},
}
],
"messages": [
*history,
{
"role": "user",
"content": [
# Volatile content lives here, AFTER the breakpoint.
{"type": "text", "text": f"<context>{json.dumps(session_ctx, sort_keys=True)}</context>"},
{"type": "text", "text": user_msg},
],
},
],
}
Verify with the response, not by inspection:
u = response.usage
assert u.cache_read_input_tokens > 0, (
f"cache miss: read={u.cache_read_input_tokens} "
f"write={u.cache_creation_input_tokens} uncached={u.input_tokens}"
)
One trap in reading usage: input_tokens is the uncached remainder only. Total prompt size is input_tokens + cache_creation_input_tokens + cache_read_input_tokens. If your agent ran for an hour and input_tokens shows 4K, that is not the whole prompt — check the sum.
What actually invalidates a Claude prompt cache?
Not everything. The API has three cache tiers, and a change only invalidates its own tier and everything below it. This is the part most teams get wrong in the conservative direction — they avoid harmless per-request changes and then break the cache with something structural.
| Change | Tools cache | System cache | Messages cache |
|---|---|---|---|
| Tool definitions (add/remove/reorder) | ❌ | ❌ | ❌ |
| Model switch | ❌ | ❌ | ❌ |
speed, web-search or citations toggle |
✅ | ❌ | ❌ |
| System prompt content | ✅ | ❌ | ❌ |
tool_choice, images, thinking on/off |
✅ | ✅ | ❌ |
| Message content | ✅ | ✅ | ❌ |
Two practical consequences:
You can flip tool_choice per request or toggle thinking without losing the tools+system cache. Don't build elaborate machinery to keep those stable.
You cannot swap the tool set for "modes." A mode switch that adds three tools and removes two invalidates position 0 and re-bills the entire conversation. If you need dynamic capability, use tool search (schemas are appended, preserving the prefix) rather than rebuilding tools.
The same logic applies to sub-agents and compaction calls. A fork that rebuilds system/tools/model with any difference from its parent misses the parent's cache entirely. Copy the parent's three fields verbatim and append fork-specific content at the end.
Two of these rows have an escape hatch, and they're gated separately. For system-prompt changes: on Claude Opus 5, Opus 4.8, Fable 5, and Mythos 5, append a {"role": "system", "content": "..."} message to messages[] instead of editing top-level system. No beta header required. The instruction lands after the cached history, so the prefix survives, and unlike a <system-reminder> stuffed into a user turn, it's a non-spoofable operator channel.
messages = [
*history,
{"role": "user", "content": user_msg},
{"role": "system", "content": "Terse mode enabled — keep responses under 40 words."},
]
Constraints: it must follow a user message (or an assistant message ending in server-tool use), it can't be messages[0], and it must be last or followed by an assistant turn. Unsupported models — including Claude Sonnet 5 — return a 400; catch it and fall back to a user-turn reminder block.
Why does my agent lose the cache after a long tool-calling turn?
Because each breakpoint walks backward at most 20 content blocks looking for a prior cache entry. Beyond that, it stops looking and writes a fresh entry.
This is the failure mode that survives every other fix, and it's specific to agentic loops. A single assistant turn that fires twelve parallel tool calls produces twelve tool_use blocks plus twelve tool_result blocks — 24 blocks in one turn. The next request's breakpoint scans back 20, never reaches the previously cached position, and misses. No error, no warning, just a cache-write charge where you expected a read.
The fix is cheap: place an intermediate breakpoint roughly every 15 blocks in long turns. You get four breakpoints per request, so budget them — one on the frozen tools+system prefix, and up to three floating through recent history.
Why doesn't a 3,000-token prompt cache at all?
Because the minimum cacheable prefix is model-dependent, and it isn't monotonic across generations:
| Model | Minimum prefix |
|---|---|
| Claude Opus 5, Fable 5, Mythos 5 | 512 tokens |
| Opus 4.8, Sonnet 5, Sonnet 4.6, Sonnet 4.5 | 1024 tokens |
| Opus 4.7, Haiku 3.5 | 2048 tokens |
| Opus 4.6, Opus 4.5, Haiku 4.5 | 4096 tokens |
A 3,000-token prompt caches fine on Claude Opus 5, Opus 4.8, and Sonnet 5 — and silently doesn't on Opus 4.6 or Haiku 4.5. There's no error. cache_creation_input_tokens comes back 0 and you conclude caching is broken.
Claude Opus 5 halved the Opus 4.8 minimum from 1024 to 512, so prompts you previously wrote off as uncacheable are worth re-checking with no code change. These minimums now apply on every platform where the model is available — the old Bedrock override for Fable 5 was removed.
Should I use the 1-hour TTL?
Only if traffic is bursty with gaps longer than five minutes. The doubled write cost is real.
Cache reads cost about 0.1x the base input price. Cache writes cost 1.25x for the default 5-minute TTL and 2x for the 1-hour TTL ({"type": "ephemeral", "ttl": "1h"}).
Run the break-even:
- 5-minute TTL: 1.25x write + 0.1x read = 1.35x for two requests, versus 2.0x uncached. Profitable at request two.
- 1-hour TTL: 2.0x write + 0.1x + 0.1x = 2.2x for three requests, versus 3.0x uncached. Needs three.
If requests arrive more often than every five minutes, the default TTL is kept warm by real traffic and the 1-hour option is pure overpayment. Reach for it when you have long idle gaps you'd otherwise re-warm across.
On pre-warming: send a max_tokens: 0 request at startup. The API runs prefill, writes the cache at your breakpoint, and returns immediately with content: [], stop_reason: "max_tokens", and zero output tokens billed. Put the cache_control on the block shared with the real request — the system prompt — not on the placeholder user message, and not via top-level auto-caching, which would key the entry to the placeholder. It's rejected with stream: true, thinking.type: "enabled", output_config.format, forced tool_choice, and inside Batches.
Skip pre-warming when traffic is continuous, the prefix is small, or you'd be speculatively warming many distinct prefixes at 1.25x each.
Why do parallel requests all miss the cache?
Because a cache entry becomes readable only once the first response begins streaming. Fire N identical-prefix requests simultaneously and all N pay full price — none can read what the others are still writing.
For fan-out over a shared document or shared few-shot block: send one request, await the first streamed token (not the full response), then release the remaining N−1. They'll read the entry the first one just wrote. On a 50K-token shared prefix with 20 parallel branches, that ordering is the difference between 20 full-price prefills and one.
The short answer
cache_read_input_tokens stays at zero because Claude prompt caching matches the exact bytes of your rendered prefix — tools, then system, then messages — and a single differing byte at position N invalidates every breakpoint after it. In practice the culprit is one of five things: volatile content (a timestamp, UUID, or unsorted JSON) sitting ahead of your breakpoint; a tool set or model that changes mid-conversation; an agentic turn appending more than 20 content blocks and blowing past the lookback window; a prefix below the model's minimum (512 to 4096 tokens depending on the model); or parallel requests racing to write the same entry. Diff the rendered prompt bytes between two consecutive requests and the answer shows up in seconds. Then assert on cache_read_input_tokens in your integration tests, because this failure is silent and it costs 1.35x forever.
Top comments (0)