# Cut Your Claude API Bill by 90% with Prompt Caching and Batches

> Source: <https://sourcefeed.dev/a/cut-your-claude-api-bill-by-90-with-prompt-caching-and-batches>
> Published: 2026-08-01 11:40:16+00:00

# Cut Your Claude API Bill by 90% with Prompt Caching and Batches

Measure real token costs, then stack prompt caching and the Batches API to slash them.

[Rachel Goldstein](https://sourcefeed.dev/u/rachel_goldstein)

## What you'll build

You'll take a working [Claude API](https://platform.claude.com/docs/en/) integration, measure exactly what its input tokens cost, then cut that bill twice: [prompt caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching) bills repeated context at 10% of the normal input rate, and the [Message Batches API](https://platform.claude.com/docs/en/build-with-claude/batch-processing) takes 50% off everything that doesn't need an immediate response. Stacked, a cached token inside a batch costs 5% of the list price.

## Prerequisites

- Python 3.9+ (tested on 3.13)
0.120.2 (current as of August 2026)`anthropic`

Python SDK- An API key from the
[Claude Console](https://platform.claude.com/) - Model:
`claude-opus-5`

— $5 per million input tokens, $25 per million output tokens. Cache pricing verified against the official docs: writes cost 1.25× base input (2× for the 1-hour TTL), reads cost 0.1×.

```
mkdir claude-costs && cd claude-costs
python3 -m venv .venv && source .venv/bin/activate
pip install anthropic==0.120.2
export ANTHROPIC_API_KEY="sk-ant-..."
```

## Step 1: Capture a cost baseline

Most real integrations re-send a big static block on every request — product docs, a style guide, few-shot examples. That's the money leak. Save this as `baseline.py`

:

``` python
import anthropic

client = anthropic.Anthropic()

# Stand-in for the static context your app re-sends on every request
DOC_LINE = (
    "Acme Widget API reference, section {}: the flux endpoint accepts JSON "
    "payloads with a `power` field measured in gigawatts and returns a signed "
    "delivery manifest within 30 seconds.\n"
)
SYSTEM = "Answer questions using only the reference below.\n\n" + "".join(
    DOC_LINE.format(i) for i in range(200)
)

resp = client.messages.create(
    model="claude-opus-5",
    max_tokens=1024,
    system=SYSTEM,
    messages=[{"role": "user", "content": "What unit is the `power` field measured in?"}],
)
u = resp.usage
print(f"input={u.input_tokens}  cache_write={u.cache_creation_input_tokens}  cache_read={u.cache_read_input_tokens}")
```

Run `python baseline.py`

. The system prompt is roughly 8,000 tokens, and `input_tokens`

confirms you're paying full price for all of them — about $0.04 per request, $40 per 1,000 requests, before output tokens.

## Step 2: Add a cache breakpoint

Caching is a prefix match: the API renders `tools`

→ `system`

→ `messages`

and caches everything up to a `cache_control`

marker. Any byte change earlier in the prefix invalidates it, so keep stable content first and volatile content (the user's question) after the breakpoint. Save as `cached.py`

:

``` python
import anthropic

client = anthropic.Anthropic()

DOC_LINE = (
    "Acme Widget API reference, section {}: the flux endpoint accepts JSON "
    "payloads with a `power` field measured in gigawatts and returns a signed "
    "delivery manifest within 30 seconds.\n"
)
SYSTEM = "Answer questions using only the reference below.\n\n" + "".join(
    DOC_LINE.format(i) for i in range(200)
)

def ask(question: str) -> None:
    resp = client.messages.create(
        model="claude-opus-5",
        max_tokens=1024,
        system=[
            {
                "type": "text",
                "text": SYSTEM,
                "cache_control": {"type": "ephemeral"},  # 5-minute TTL
            }
        ],
        messages=[{"role": "user", "content": question}],
    )
    u = resp.usage
    print(f"input={u.input_tokens}  cache_write={u.cache_creation_input_tokens}  cache_read={u.cache_read_input_tokens}")

ask("What unit is the `power` field measured in?")
ask("How fast is the delivery manifest returned?")
```

The first call writes the cache at 1.25× input price; every identical-prefix call within the 5-minute TTL (each read refreshes it) bills those tokens at 0.1× — the 90% cut. You get up to 4 breakpoints per request, and on `claude-opus-5`

a prefix must be at least 512 tokens to cache at all.

## Step 3: Batch the non-urgent work

Evaluations, backfills, and nightly summarization don't need answers in seconds. The Batches API takes any list of Messages requests (up to 100,000 requests or 256 MB per batch), processes them asynchronously — most batches finish within an hour — and bills all usage at 50% of standard prices. Caching works inside batches too; use the 1-hour TTL since processing usually outlives the 5-minute one. Save as `batch.py`

:

``` python
import time

import anthropic
from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
from anthropic.types.messages.batch_create_params import Request

client = anthropic.Anthropic()

DOC_LINE = (
    "Acme Widget API reference, section {}: the flux endpoint accepts JSON "
    "payloads with a `power` field measured in gigawatts and returns a signed "
    "delivery manifest within 30 seconds.\n"
)
SYSTEM = "Answer questions using only the reference below.\n\n" + "".join(
    DOC_LINE.format(i) for i in range(200)
)

shared_system = [
    {
        "type": "text",
        "text": SYSTEM,
        "cache_control": {"type": "ephemeral", "ttl": "1h"},
    }
]

questions = [
    "What unit is the `power` field measured in?",
    "What does the flux endpoint return?",
    "Summarize the reference in one sentence.",
]

batch = client.messages.batches.create(
    requests=[
        Request(
            custom_id=f"q-{i}",
            params=MessageCreateParamsNonStreaming(
                model="claude-opus-5",
                max_tokens=1024,
                system=shared_system,
                messages=[{"role": "user", "content": q}],
            ),
        )
        for i, q in enumerate(questions)
    ]
)
print(f"Created {batch.id}, status: {batch.processing_status}")

while batch.processing_status != "ended":
    time.sleep(30)
    batch = client.messages.batches.retrieve(batch.id)
    print(f"status: {batch.processing_status}")

for result in client.messages.batches.results(batch.id):
    if result.result.type == "succeeded":
        text = next(b.text for b in result.result.message.content if b.type == "text")
        print(f"{result.custom_id}: {text}")
    else:
        print(f"{result.custom_id}: {result.result.type}")
```

Results arrive in any order, so always key on `custom_id`

, never on position. Results stay downloadable for 29 days.

## Verify it works

`python cached.py`

should print (token counts vary by a few):

```
input=15  cache_write=8021  cache_read=0
input=14  cache_write=0     cache_read=8021
```

The second line is the win: 8,021 tokens billed at $0.50/MTok instead of $5. `python batch.py`

should end with:

```
Created msgbatch_013Zva2CMHLNnXjNJJKqJ2EF, status: in_progress
status: ended
q-0: The `power` field is measured in gigawatts.
q-1: The flux endpoint returns a signed delivery manifest.
q-2: ...
```

At 1,000 requests against this 8,000-token prompt, input costs drop from ~$40 uncached to ~$4 with caching, and ~$2 cached inside a batch — with output tokens also half price in the batch.

## Troubleshooting

** cache_read_input_tokens is 0 on every request.** Caching fails silently, never with an error. Two usual causes: the prefix is under the 512-token minimum for

`claude-opus-5`

(other models require 1,024–4,096), or a byte differs between requests — a timestamp interpolated into the system prompt, an unsorted `json.dumps`

, a tool list that changes order. Diff the rendered prompts of two consecutive requests.** anthropic.AuthenticationError: Error code: 401 - {'type': 'error', 'error': {'type': 'authentication_error', 'message': 'invalid x-api-key'}}** — the key isn't reaching the client. Re-run

`export ANTHROPIC_API_KEY=...`

in the same shell as the script, and check for a stale key in `.env`

or your shell profile overriding it.** AttributeError: 'MessageBatchErroredResult' object has no attribute 'message'** — you read

`result.result.message`

without checking the result type. Only `succeeded`

results carry `.message`

; branch on `result.result.type`

first (`errored`

results carry `.error`

instead).**Batch results come back expired.** The batch hit the 24-hour processing ceiling before those requests ran — common under heavy demand or when a batch trips rate limits. Expired requests aren't billed; collect their

`custom_id`

s and resubmit just those in a new batch.## Next steps

Point `cached.py`

at your real system prompt and tool definitions — a breakpoint on the last system block caches tools and system together. Audit for silent cache invalidators (timestamps, UUIDs, non-deterministic serialization) before trusting your hit rate. Then look at the [token counting endpoint](https://platform.claude.com/docs/en/build-with-claude/token-counting) to estimate costs pre-flight, and the [rate limits page](https://platform.claude.com/docs/en/api/rate-limits) — batches have their own limits separate from the Messages API.

## Sources & further reading

-
[Prompt caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching)— platform.claude.com -
[Batch processing](https://platform.claude.com/docs/en/build-with-claude/batch-processing)— platform.claude.com -
[Pricing](https://platform.claude.com/docs/en/pricing)— platform.claude.com -
[Anthropic Python SDK](https://github.com/anthropics/anthropic-sdk-python)— github.com

[Rachel Goldstein](https://sourcefeed.dev/u/rachel_goldstein)· Dev Tools Editor

Rachel has been embedded in the developer tooling ecosystem for nearly eight years, covering everything from IDE wars and package-manager drama to the quiet rise of AI-assisted coding. She has a soft spot for open-source maintainers and an unhealthy number of terminal emulators installed on a single laptop.

## Discussion 0

No comments yet

Be the first to weigh in.
