# Stop Paying for the Same Tokens Twice: A Practical Guide to Prompt Caching

> Source: <https://dev.to/mukul_sharma_61fc4dd6f9d8/stop-paying-for-the-same-tokens-twice-a-practical-guide-to-prompt-caching-4938>
> Published: 2026-08-05 19:03:27+00:00

You've built a chatbot. Every turn, you re-send the whole conversation — the 8,000-token system prompt, the uploaded PDF, the 15 messages of history — just so the model can answer "and what about Mars?"

The model re-reads all of it. Every. Single. Time. You pay full price for all of it. Every. Single. Time.

Prompt caching fixes this. It's roughly one extra line of JSON, and it can cut your input costs by ~90% on the repeated part while making responses noticeably faster.

Add `cache_control`

at the top level of your request:

```
response = client.messages.create(
    model="claude-opus-5",
    max_tokens=1024,
    cache_control={"type": "ephemeral"},   # <-- this is the whole trick
    system="You are an AI assistant tasked with analyzing literary works...",
    messages=[
        {"role": "user", "content": "Analyze the major themes in 'Pride and Prejudice'."}
    ],
)
print(response.usage)
```

That's **automatic caching**. The API caches everything up to and including the last cacheable block in your request. Next time you send a request that starts with the same content, that prefix is read from cache instead of reprocessed.

Same thing in curl, if that's more your speed:

```
curl https://api.anthropic.com/v1/messages \
  -H "content-type: application/json" \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -d '{
    "model": "claude-opus-5",
    "max_tokens": 1024,
    "cache_control": {"type": "ephemeral"},
    "system": "You are an AI assistant tasked with analyzing literary works.",
    "messages": [{"role": "user", "content": "Analyze the major themes in Pride and Prejudice."}]
  }'
```

This is the single most important thing to internalize.

Your prompt is read in a fixed order:

```
tools  →  system  →  messages
```

Caching works on **prefixes** of that sequence. When you mark a block with `cache_control`

, you're saying: *"cache everything from the start of the request up to and including this block."*

Which means:

Three price tiers instead of one:

| Multiplier vs. base input | |
|---|---|
5-minute cache write
|
1.25× |
1-hour cache write
|
2× |
Cache read (hit) |
0.1× |

So for Claude Opus 5 ($5/MTok input):

```
"usage": {
  "cache_read_input_tokens": 100000,
  "cache_creation_input_tokens": 0,
  "input_tokens": 50
}
```

`input_tokens`

is **not** your total input. It's only the tokens *after* your last cache breakpoint. Total is:

```
total = cache_read_input_tokens + cache_creation_input_tokens + input_tokens
```

So the example above processed 100,050 tokens, not 50. Handy side effect: cache hits don't count against your rate limits the way fresh input does, so effective throughput goes up too.

**Quick sanity check:** if both `cache_creation_input_tokens`

and `cache_read_input_tokens`

are `0`

, nothing was cached. Most likely you're under the minimum length (see below) — the API won't error, it just silently skips caching.

Prompts shorter than a per-model floor simply won't cache:

| Model | Minimum cacheable tokens |
|---|---|
| Opus 5, Fable 5, Mythos 5 | 512 |
| Opus 4.8, Sonnet 5 / 4.6 / 4.5 | 1,024 |
| Opus 4.7, Haiku 3.5 | 2,048 |
| Opus 4.6, Opus 4.5, Haiku 4.5 | 4,096 |

If you're *just* under the line, it's often worth padding the cached section (more examples, more context) to get over it. Cache reads are cheap enough that the extra tokens pay for themselves.

With automatic caching, the breakpoint walks forward on its own as the conversation grows:

| Request | What happens |
|---|---|
| 1 |
`System + U1 + A1 + **U2**` → everything written to cache |
| 2 |
`... + A2 + **U3**` → System→U2 read from cache, A2+U3 written |
| 3 |
`... + A3 + **U4**` → System→U3 read from cache, A3+U4 written |

No bookkeeping. No moving markers around. Each turn reads the whole prior conversation from cache and only writes the new bit. This is the single best default for chat apps.

Put `cache_control`

on individual blocks when different parts of your prompt change at different rates. You get up to **4 breakpoints**.

Classic RAG-agent layout:

```
{
  "tools": [ /* ... */, { "name": "get_document", "cache_control": {"type": "ephemeral"} } ],
  "system": [
    { "type": "text", "text": "You are a research assistant...", "cache_control": {"type": "ephemeral"} },
    { "type": "text", "text": "# Knowledge Base\n## Doc 1...", "cache_control": {"type": "ephemeral"} }
  ],
  "messages": [
    { "role": "user", "content": [
      { "type": "text", "text": "Tell me about Perseverance.", "cache_control": {"type": "ephemeral"} }
    ]}
  ]
}
```

Four independent segments:

Here's the bug I want you to remember, because it's expensive and silent.

Your prompt: blocks 1–5 are a big static system context. Block 6 is `f"[{timestamp}] {user_message}"`

. You put `cache_control`

on block 6, because it's the end and that seems right.

`cache_control`

to A related gotcha. When looking for a cache hit, the system checks your breakpoint's position and then walks backward — but only **20 blocks**.

Default TTL is **5 minutes**, and it refreshes for free every time you hit the cache. An active chat session basically keeps itself warm.

```
"cache_control": { "type": "ephemeral", "ttl": "1h" }
```

Reach for 1h when:

Latency-sensitive app? The first user of the day eats the cache-miss penalty. Unless you warm it up first:

```
SYSTEM_PROMPT = [{
    "type": "text",
    "text": "You are an expert software engineer...",
    "cache_control": {"type": "ephemeral"},
}]
def prewarm_cache():
    client.messages.create(
        model="claude-opus-5",
        max_tokens=0,                                  # no output generated
        system=SYSTEM_PROMPT,
        messages=[{"role": "user", "content": "warmup"}],
    )
```

`max_tokens: 0`

reads your prompt in, writes the cache at your breakpoint, and returns immediately with an empty `content`

array and `stop_reason: "max_tokens"`

. Zero output tokens billed. (You still pay the cache write, naturally.)

Two things to get right:

`"warmup"`

placeholder — otherwise the entry is keyed to the placeholder and real traffic never hits it. This is why pre-warming needs an explicit breakpoint rather than automatic caching.`effort`

setting as your real requests. Those get rendered into the prompt, so a mismatched pre-warm writes an entry nobody uses.
`max_tokens: 0`

is rejected with `stream: true`

, extended thinking, structured outputs, forced `tool_choice`

, or inside a Batches request.Cache hits need a **100% byte-identical** prefix. Things that invalidate:

| Change | Blast radius |
|---|---|
| Tool definitions | Everything |
| Toggling web search / citations | System + messages |
| Switching fast mode | System + messages |
`tool_choice` |
Messages |
| Adding/removing images anywhere | Messages |
Thinking config or `effort`
|
Messages (and sometimes more) |

One sneaky one: some languages (**Go, Swift**) randomize map key order when serializing JSON. If your `tool_use`

blocks come out with shuffled keys, your cache never hits and you'll have no idea why. Pin the ordering.

Also: caches are isolated per organization, and per workspace on the Claude API. And a cache entry only becomes available *after the first response begins* — so firing 10 parallel requests with the same prefix gives you 10 misses. Send one, wait, then fan out.

Cache not hitting? Run down this list:

`tool_choice`

, image presence, thinking config, and `effort`

*Got a caching setup that surprised you — good or bad? Drop it in the comments.* 🚀
