The Claude Message Batches API runs thousands of requests in the background for half the normal price. If your job does not need an answer in seconds, it is the easiest 50% saving in the whole Claude API.
This Python tutorial covers the limits, a full create, poll and results script, error handling, and how to stack prompt caching on top for even lower bills.
- Discount: every batch token costs 50% of standard price, input and output.
- Limits: up to 100,000 requests or 256 MB per batch. Most batches finish in under an hour, with a 24-hour maximum.
- Results: download them for 29 days. They come back in any order, so match on
custom_id. - Not allowed: streaming, fast mode (
speed), server-sidefallbacksandmax_tokens: 0. - Stack savings: prompt caching works inside batches. Use the 1-hour cache for shared prefixes.
What the Message Batches API Is
A batch is a bundle of normal Messages API requests that you send in one call. Anthropic processes them asynchronously, then gives you a results file to download.
Each request in the batch can be different. You can mix models, system prompts, tools, images and multi-turn conversations in the same batch.
Good uses for batches include:
- Bulk classification: tagging support tickets, reviews or emails.
- Content work: summaries, translations or product descriptions at scale.
- Evaluations: running a test set of prompts against a new model or prompt.
- Data extraction: pulling fields out of thousands of documents.
- Nightly jobs: anything that runs on a schedule with no user waiting.
Batch Pricing: 50% Off Every Model
All batch usage is charged at 50% of standard API prices. Here are the current batch rates for the main models, per million tokens.
| Model | Standard input / output | Batch input / output |
|---|---|---|
| Claude Fable 5.2 | $8 / $40 | $4 / $20 |
| Claude Opus 5.5 | $4 / $20 | $2 / $10 |
| Claude Sonnet 5.5 | $2 / $10 | $1 / $5 |
| Claude Haiku 5.5 (prompts up to 100K) | $0.10 / $0.50 | $0.05 / $0.25 |
| Claude Haiku 5.5 (prompts over 100K) | $0.50 / $2.50 | $0.25 / $1.25 |
A quick cost example
Say you classify 10,000 tickets on Opus 5.5. Each request uses 1,500 input tokens and 300 output tokens.
- Standard API: 15M input tokens x $4 = $60, plus 3M output tokens x $20 = $60. Total $120.
- Batch API: the same tokens at half price. Total $60.
- Batch on Haiku 5.5: 15M x $0.05 = $0.75, plus 3M x $0.25 = $0.75. Total $1.50, if Haiku is accurate enough for your task.
Only succeeded results are billed. Errored, canceled and expired requests cost nothing. For every other price on the Claude API, see our Claude API pricing guide.
Limits and Rules You Must Know
Batches have a few hard rules. Most “my batch failed” problems come from one of these.
| Rule | Value |
|---|---|
| Max requests per batch | 100,000 |
| Max batch size | 256 MB (413 request_too_large if over) |
| Typical processing time | Under 1 hour |
| Maximum processing time | 24 hours, then unfinished requests expire |
| Results available for | 29 days from created_at |
custom_id format |
1 to 64 characters: letters, numbers, _ and -, unique per batch |
| Batch API requests per minute | 1,000 (Start), 2,000 (Build), 4,000 (Scale) |
| Requests waiting in the queue | 200,000 (Start), 300,000 (Build), 500,000 (Scale) |
These request parameters are rejected inside a batch:
stream: true: results come back as one file, not a stream.speed: "fast": fast mode is for synchronous latency, so it does not apply. Our fast mode guide explains when that is worth paying for.fallbacks: server-side refusal fallbacks are not supported. Retry refused items yourself.max_tokens: 0: cache pre-warming does not work in batches.
Warning: Batches are not eligible for zero data retention or HIPAA. Anthropic stores inputs and outputs for up to 29 days so you can download results. Do not batch data that your policy says must never be stored.
Set Up Python
You need Python 3.10 or newer, the official SDK and an API key from the Claude Console.
pip install anthropic
export ANTHROPIC_API_KEY="sk-ant-..." # Windows PowerShell: $env:ANTHROPIC_API_KEY="sk-ant-..."
New to the SDK? Our Claude API Python tutorial covers keys, the first request and streaming.
Step by Step: Create, Poll and Read a Batch
- Test one request first. Send a single request through the normal Messages API. Batch validation errors only show up after the batch ends, so catch shape mistakes early.
- Build your request list. Wrap each request as a
Requestwith a uniquecustom_idand aparamsobject. - Create the batch. Call
client.messages.batches.create(requests=...)and save the returned batch ID. - Poll the status. Call
client.messages.batches.retrieve(id)every minute or so untilprocessing_statusis"ended". - Stream the results. Loop over
client.messages.batches.results(id)and key each result bycustom_id. - Handle failures. Fix and resend
invalid_request_erroritems. Resend server errors and expired items as they are. - Clean up. Save the results to your own storage, then delete the batch if you no longer need it on Anthropic’s side.
Create the batch
import anthropic
from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
from anthropic.types.messages.batch_create_params import Request
client = anthropic.Anthropic()
batch = client.messages.batches.create(
requests=[
Request(
custom_id="ticket-001",
params=MessageCreateParamsNonStreaming(
model="claude-opus-5-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Classify this ticket: My invoice is wrong."}],
),
),
Request(
custom_id="ticket-002",
params=MessageCreateParamsNonStreaming(
model="claude-opus-5-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Classify this ticket: The app crashes on login."}],
),
),
]
)
print(batch.id, batch.processing_status) # msgbatch_..., in_progress
Poll until it ends
The batch moves through three states: in_progress, canceling and ended. The request_counts object shows how many requests are processing, succeeded, errored, canceled or expired.
import time
while True:
batch = client.messages.batches.retrieve(batch.id)
if batch.processing_status == "ended":
break
print("Still processing:", batch.request_counts.processing)
time.sleep(60)
print(batch.request_counts)
Tip: Poll gently. Batch endpoints share one requests-per-minute limit across all models, so a tight loop over many batches can earn you 429 errors.
Read the results
Results arrive as JSONL, one object per request, in any order. Never match them by position.
for result in client.messages.batches.results(batch.id):
outcome = result.result
match outcome.type:
case "succeeded":
msg = outcome.message
text = "".join(b.text for b in msg.content if b.type == "text")
print(result.custom_id, "=>", text)
case "errored":
if outcome.error.error.type == "invalid_request_error":
print("Fix and resend:", result.custom_id)
else:
print("Retry as is:", result.custom_id)
case "canceled" | "expired":
print("Resend later:", result.custom_id)
Full Working Script
This script classifies a list of support tickets, waits for the batch, saves results to a JSON file, and collects anything that needs a retry.
import json
import time
import anthropic
from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
from anthropic.types.messages.batch_create_params import Request
client = anthropic.Anthropic()
MODEL = "claude-opus-5-5" # try "claude-haiku-5-5" for cheap bulk labels
SYSTEM = (
"You label customer support tickets. "
"Reply with exactly one word: billing, bug, account, feature or other."
)
tickets = {
"t-001": "I was charged twice this month.",
"t-002": "The export button does nothing on Firefox.",
"t-003": "Please add dark mode to the dashboard.",
}
def build_requests(items):
return [
Request(
custom_id=tid,
params=MessageCreateParamsNonStreaming(
model=MODEL,
max_tokens=256,
system=SYSTEM,
messages=[{"role": "user", "content": text}],
),
)
for tid, text in items.items()
]
def wait_for(batch_id, delay=30, max_delay=300):
while True:
b = client.messages.batches.retrieve(batch_id)
if b.processing_status == "ended":
return b
time.sleep(delay)
delay = min(delay * 2, max_delay) # back off slowly
batch = client.messages.batches.create(requests=build_requests(tickets))
print("Created", batch.id)
batch = wait_for(batch.id)
print("Counts:", batch.request_counts)
labels, retry = {}, {}
for r in client.messages.batches.results(batch.id):
if r.result.type == "succeeded":
msg = r.result.message
if msg.stop_reason == "refusal":
retry[r.custom_id] = tickets[r.custom_id]
continue
labels[r.custom_id] = "".join(
b.text for b in msg.content if b.type == "text"
).strip()
else:
retry[r.custom_id] = tickets[r.custom_id]
with open("labels.json", "w") as f:
json.dump(labels, f, indent=2)
print("Saved", len(labels), "labels. Need retry:", list(retry))
To retry, call build_requests(retry) and send a new batch. Fix the request body first for any invalid_request_error items, or they will fail again.
Note: Fable 5.2, Opus 5.5 and Sonnet 5.5 reject forced tool_choice (any or a named tool) with a 400, and that rule applies inside batches too. For JSON output, use structured outputs instead. Our structured outputs tutorial shows how.
Combine Batches With Prompt Caching
The batch discount and prompt caching stack. If every request shares a long system prompt or document, caching that prefix cuts the input cost again.
The catch is timing. Batch requests run in parallel and in any order, so cache hits are best-effort. Anthropic reports typical hit rates between 30% and 98%.
How to get more cache hits
- Use identical cache blocks: the cached prefix must match byte for byte in every request.
- Use the 1-hour cache: batches often take 5 to 60 minutes, which is longer than the default 5-minute cache.
- Warm the cache first: send one request with the shared prefix, wait for it to finish, then submit the rest.
- Put shared content first: tools, then system, then messages. Anything that changes goes after the cache breakpoint.
LONG_DOC = open("handbook.txt").read() # shared by every request
def cached_request(cid, question):
return Request(
custom_id=cid,
params=MessageCreateParamsNonStreaming(
model="claude-opus-5-5",
max_tokens=1024,
system=[
{"type": "text", "text": "Answer using only the handbook below."},
{
"type": "text",
"text": LONG_DOC,
"cache_control": {"type": "ephemeral", "ttl": "1h"},
},
],
messages=[{"role": "user", "content": question}],
),
)
Check usage.cache_read_input_tokens in each succeeded message. If it stays at zero, something in your prefix is changing between requests. Prefixes shorter than 512 tokens on current models are not cached at all.
The 1-hour cache write costs 2x the base input price, while reads are much cheaper than base input. For a deeper look at the maths, read our prompt caching guide.
List, Cancel and Delete Batches
# List recent batches (auto-paginates)
for b in client.messages.batches.list(limit=20):
print(b.id, b.processing_status, b.created_at)
# Cancel a running batch (ends with partial results)
client.messages.batches.cancel("msgbatch_...")
# Delete a finished batch and its stored data
client.messages.batches.delete("msgbatch_...")
A canceled batch moves to canceling, then ended. Requests already finished stay in the results. To delete a batch that is still running, cancel it first.
Batches belong to a Workspace. You only see batches created in the Workspace your API key belongs to.
Troubleshooting: Common Problems and Fixes
413 request_too_large
Your batch is over 256 MB. Split it into several smaller batches, or move large files to the Files API and reference them by ID.
Every result is errored with invalid_request_error
The request shape is wrong, for example a disabled thinking setting on Fable 5.2 or a forced tool_choice. Send one copy through the normal Messages API to see the exact error.
Many results expired
The batch hit the 24-hour limit before those requests ran, often during heavy demand. Resend the expired items in a new batch, and split very large jobs across the day.
Results do not match my inputs
Results come back in any order. Always look up the input by custom_id, never by list position.
Duplicate custom_id error
Each custom_id must be unique in a batch and match 1 to 64 letters, numbers, underscores or dashes. Use your own record IDs.
Cache reads are always zero
Something in the prefix changes, such as a timestamp in the system prompt. Keep the cached block identical, switch to the 1-hour cache, and warm it with one request first.
429 errors on batch endpoints
You are polling or creating batches too fast. Slow your loop and back off. Add exponential backoff to every retry.
Frequently Asked Questions
How much does the Claude Batch API save?
Batch requests cost 50% of standard prices for both input and output tokens. Fable 5.2 drops to $4 / $20 per million tokens, and Opus 5.5 to $2 / $10.
How long does a Claude batch take?
Most batches finish in under an hour. Any request not processed within 24 hours expires and is not billed.
How many requests can one batch hold?
Up to 100,000 requests or 256 MB, whichever comes first. Your tier also limits how many batch requests can wait in the queue at once.
Can I stream batch results?
You cannot set stream: true on batch requests. The SDK does stream the results file line by line, so large result sets do not fill your memory.
Can I use fast mode or Fable 5.2 in a batch?
Fable 5.2 works in batches at $4 / $20. Fast mode does not: a request with speed returns a validation error.
Do prompt caching and batch discounts stack?
Yes. Cache hits are best-effort in batches, so use identical cache blocks and the 1-hour cache duration for the best hit rate.
How long are batch results kept?
Results can be downloaded for 29 days after the batch was created. You can delete a finished batch sooner with the delete endpoint.




