It started on a night spent sorting assets for a wallpaper app. A few thousand images were waiting to be classified. I could have sent each one to Claude individually for a caption and a category, but I didn't need real-time answers. If a job ran while I slept and the results were ready by morning, that was plenty.
That "not urgent, but high volume" shape is exactly what Message Batches is built for. You submit requests in bulk and they're processed asynchronously.
What I hadn't understood on my first run, though, was the actual discount rate, what the deadline really meant, or which characters custom_id would accept. What follows is the record of correcting all three.
The discount is 50% — the 90% comes from stacking
I had assumed that simply moving work onto batches would cut the bill by ninety percent. The real figure, per the official documentation, is a flat 50%, applied uniformly to input tokens, output tokens, and special tokens alike.
So where does ninety percent come from? I worked the numbers for my own classification job: 3,000 requests, a 900-token shared prompt plus a 60-token per-image description, 8 output tokens for the one-word category, running on Claude Haiku 4.5 (synchronous pricing of $1 input and $5 output per million tokens; batch is half of that).
| Setup | Cost per 3,000 requests | vs. synchronous |
|---|---|---|
| Synchronous API, no caching | $3.0000 | — |
| Message Batches | $1.5000 | 50% lower |
| Message Batches + 1-hour cache | $0.2859 | 90.5% lower |
The third row breaks down as $0.0009 for the cache write, $0.1350 for cache reads, $0.0900 for the unique portion of the input, and $0.0600 for output — assuming the 900-token shared prompt is written once and read by the remaining 2,999 requests.
In other words, the ninety percent came from stacking three things: batching, model choice, and prompt caching. It isn't what batching does on its own. The docs state plainly that the caching and batch discounts stack, so this layering is entirely above board.
One caveat: cache hits inside a batch are best-effort, with a documented range of 30% to 98%. A 5-minute TTL tends to expire mid-batch, which is why the docs point toward the 1-hour TTL for batch workloads. That matched my experience — with the 5-minute default, reads barely registered. I've written up the caching side separately in my notes on prompt caching.
Start with the smallest possible run
Rather than firing off thousands of requests, confirm the shape with two or three. The custom_id is the key you'll use to map results back to your source data, so give it a meaningful value.
import anthropic
client = anthropic.Anthropic()
batch = client.messages.batches.create(
requests=[
{
"custom_id": "wallpaper_0001",
"params": {
"model": "claude-haiku-4-5-20251001",
"max_tokens": 512,
"messages": [
{"role": "user", "content": "Classify the mood of this wallpaper in one word: a faint ring of light in the night sky"}
],
},
},
{
"custom_id": "wallpaper_0002",
"params": {
"model": "claude-haiku-4-5-20251001",
"max_tokens": 512,
"messages": [
{"role": "user", "content": "Classify the mood of this wallpaper in one word: a mountain range in morning mist"}
],
},
},
]
)
print(f"Batch ID: {batch.id}")There was no reason to reach for a top-tier model on a task this routine. Haiku handled it, and combined with the batch discount the cost dropped another notch.
I put filenames straight into custom_id, and they bounced
In my first version, I used the image filenames as custom_id values — things like wallpaper_0001.jpg. It felt like good design, since the mapping back to source data was self-evident.
But custom_id is constrained to ^[a-zA-Z0-9_-]{1,64}$: alphanumerics, hyphens, and underscores only. The period in a file extension puts the value outside that set immediately. There's a 64-character ceiling too.
What makes this more awkward is that validation of the params object happens asynchronously — the docs note that validation errors surface once the whole batch has finished processing. A formatting mistake can be something you learn about the next morning.
So I normalized custom_id down to safe characters and kept a reverse lookup table alongside it.
import re
def to_custom_id(filename: str) -> str:
"""Normalize to alphanumerics, hyphen, underscore; cap at 64 characters."""
stem = filename.rsplit(".", 1)[0]
safe = re.sub(r"[^A-Za-z0-9_-]", "_", stem)
return safe[:64]
# Always keep the reverse map — results only come back keyed by custom_id
index = {}
for name in image_files:
cid = to_custom_id(name)
if cid in index:
raise ValueError(f"custom_id collision: {cid} <- {name}")
index[cid] = nameThe collision check matters because normalization can flatten distinct filenames into the same value. Both img.001.jpg and img-001.jpg become img_001. One such pair hidden among a few thousand files is enough to break your result mapping silently.
ended means finished, not successful
A batch is still processing right after creation. Most finish within the hour, though under load it can stretch to hours. Hammering the endpoint every few seconds buys nothing, so I wait a few minutes and then poll at 60-second intervals.
import time
while True:
batch = client.messages.batches.retrieve(batch.id)
if batch.processing_status == "ended":
break
counts = batch.request_counts
print(f"Processing... ok {counts.succeeded} / err {counts.errored} / in-flight {counts.processing}")
time.sleep(60)
print("Batch ended")What you're checking is not "did everything succeed" but "has processing reached ended." The request_counts object carries five fields: processing, succeeded, errored, canceled, and expired. Even once a batch is ended, its contents are a mix — confuse the two and the failures drop on the floor.
Result order isn't guaranteed
This is what caught me first. Results don't necessarily come back in submission order; the docs state outright that batch results may not match input order. The safe approach is to stream them one at a time and map each back to your source data by custom_id.
results = {}
for item in client.messages.batches.results(batch.id):
cid = item.custom_id
if item.result.type == "succeeded":
results[cid] = item.result.message.content[0].text
elif item.result.type == "errored":
# Don't swallow it — push onto a retry list
print(f"Failed: {cid} -> {item.result.error}")
results[cid] = None
elif item.result.type == "expired":
print(f"Expired: {cid}")
results[cid] = None
ok = sum(1 for v in results.values() if v is not None)
print(f"Retrieved {ok} / {len(results)}")Here's what each result.type means:
| type | Meaning | Billed? |
|---|---|---|
| succeeded | Successful; includes the message itself | Yes |
| errored | Invalid request, or a server-side error | No |
| canceled | Canceled before reaching the model | No |
| expired | Not sent within the 24-hour window | No |
Not being billed for failures means there's no reason to hesitate about resubmitting. I collect the errored and expired custom_ids and send them back as a smaller batch. Automate that step and the next morning becomes a review rather than a repair job.
The 24-hour deadline and the 29-day window
The timing rules feed directly into how you design around them. A batch that doesn't finish within 24 hours expires. You can read results either when every request has completed or once 24 hours have passed — whichever comes first.
Results then stay downloadable for 29 days. The clock starts at creation time, not at the moment processing ended. Miss that distinction and the longer a batch runs, the less time you actually have to collect from it.
In my own setup, I write results to my own storage the moment they arrive, so nothing depends on Anthropic-side retention. Finished batches can also be deleted; if you want to remove one that's still in flight, cancel it first.
There's one more note worth heeding: because batches run with high concurrency, they may go slightly over a workspace's configured spend limit. If you operate close to your ceiling, leave yourself some headroom.
Size your chunks by what you'd want to roll back
A single batch holds up to 100,000 requests or 256 MB, whichever comes first. Exceed the size limit and you'll get a request_too_large error.
Even so, I don't pack them to the ceiling — I submit 2,000 at a time. The reasoning is simple: when you need to re-run part of the work, a smaller rollback unit is easier to live with. Chunk by sequence number and log a batch ID per chunk, and you can see at a glance how far the job got.
def chunk(items, size=2000):
for i in range(0, len(items), size):
yield items[i:i + size]
for n, group in enumerate(chunk(all_requests)):
b = client.messages.batches.create(requests=group)
print(f"chunk {n}: {b.id} ({len(group)} requests)")Rate limits for batches are tracked separately from the synchronous Messages API — batch consumption doesn't eat into your synchronous ceiling. That separation is what lets you push heavy background work through without stalling your app's normal paths. For deeper production patterns, see my write-up on async design with the Messages Batches API.
Decide what stays off the batch path
Some things simply can't go into a batch. stream: true is rejected, since results come back as a single file rather than a stream. So is max_tokens: 0 for cache pre-warming — an ephemeral cache entry written during batch processing would expire before the follow-up request ran.
More consequential than the parameters the API refuses, though, is the judgment about what you choose not to move. Push work that has to answer a user's tap into a batch and you will degrade the experience. My own line is to batch only work nobody is waiting on: drafting App Store review replies, classifying assets — jobs where "ready by morning" is enough.
As an indie developer, the waiting time itself is time I can spend elsewhere. Since shifting this work to run overnight and land by morning, the shape of my day has gotten noticeably easier to plan.
For a next step, pick one piece of "not urgent" work you already have and confirm the round trip with a two- or three-request batch. Once you've normalized your custom_ids and kept a reverse map, scaling the count up is the easy part.
Thank you for reading.