Message Batches API
Anthropic's async bulk-processing endpoint. Hand Claude thousands of requests at once, poll for completion, and collect the results — all at about 50% of the standard token cost. Learn the real SDK calls, how to match results back by custom_id, per-item error handling, and when to batch vs stream.
Learning objectives
- Say what the Message Batches API is and why async bulk runs at ~50% cost.
- Build a batch request list with a
custom_idon every item. - Submit with
client.messages.batches.create(...)and pollprocessing_status. - Retrieve results with
.results(id)and match them back bycustom_id. - Handle per-item outcomes (succeeded / errored / expired) instead of one all-or-nothing failure.
- Decide when to batch vs stream, and design a production batch pipeline (idempotency, chunking, monitoring).
1 · What the Message Batches API is essential
Most of the time you call Claude one request at a time and wait for the answer — that's the synchronous Messages API you already know. But sometimes you have thousands of requests and no human waiting on any single one: classify a week of support tickets overnight, summarize 50,000 documents, generate embeddings-style labels for a whole catalog. For that, Anthropic gives you the Message Batches API.
A batch is exactly what it sounds like: you hand Claude a list of independent requests in one call, Anthropic processes them asynchronously (in the background, on its own schedule), and you come back later to collect the results. Each request in the batch is an ordinary Messages API request — same model, max_tokens, messages — just wrapped with a label you choose (custom_id) so you can tell the answers apart.
The trade you're making: you give up immediacy (results aren't instant — most batches finish within an hour, and the hard cap is 24 hours) and you get back ~50% off every input and output token. For non-urgent bulk work, that's a huge lever.
2 · The batch lifecycle essential
Every batch goes through the same four beats. Learn this shape once and the code below is just wiring:
This picture is the entire Message Batches API on one line. Read it left to right — it's a pipeline, not a single call, and each box is one thing you do (or Anthropic does) before the next.
- Build requests — you assemble a Python list, one entry per job, and give each entry a
custom_idlabel of your own choosing. That label is the thread that ties an answer back to its question later. - Submit batch — you hand the whole list to
batches.create(...)in one call and get back a batch object with anid. That id is your only handle on the job from here on. - Poll status — processing happens in the background, so nobody hands you the results; you have to ask. You call
retrieve(id)on a loop and watchprocessing_statusuntil it reads"ended". - Retrieve results — you stream the answers back. They arrive in any order, so you use the
custom_ids to line each one up with the request that produced it. - The caption under the arrows — ~50% of standard token cost — is the whole reason to take this slower path: async bulk work is billed at roughly half price.
In short: Whenever a batch confuses you, come back to these four boxes. Every code block below is just one of them written out in the real SDK.
Read it left to right. First you build a list of requests, each tagged with a custom_id. You submit the list and get back a batch object with an id and a processing_status. Because processing happens in the background, you poll — ask "are you done yet?" — until the status becomes "ended". Then you retrieve the results, which arrive in any order, and use your custom_ids to line each answer back up with the request that produced it. The whole run is billed at roughly half the normal per-token price.
3 · Recipe 1 — submit a batch essential
Here is the real SDK, end to end for the submit step. Each request is wrapped in a Request with a custom_id you choose and a params block that is a normal non-streaming Messages request. The custom_id is your label — use something meaningful (a ticket ID, a row key) so you can match results later.
submit.py# needs: pip install anthropic + ANTHROPIC_API_KEY; makes real API calls
import anthropic
from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
from anthropic.types.messages.batch_create_params import Request
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the environment
tickets = {
"ticket-8801": "I was charged twice for my subscription this month.",
"ticket-8802": "How do I export my data to CSV?",
"ticket-8803": "The app crashes every time I open the reports tab.",
}
requests = [
Request(
custom_id=ticket_id, # YOUR label — match results by this
params=MessageCreateParamsNonStreaming(
model="claude-haiku-4-5", # cheap+fast: classification is easy work
max_tokens=64,
system="Classify the ticket as one word: billing, howto, or bug.",
messages=[{"role": "user", "content": text}],
),
)
for ticket_id, text in tickets.items()
]
batch = client.messages.batches.create(requests=requests)
print(batch.id) # e.g. msgbatch_01AbC... — save this
print(batch.processing_status) # "in_progress"
This is the real thing — the actual anthropic SDK call that submits a batch. It needs pip install anthropic and an ANTHROPIC_API_KEY, and it makes a live API request. The shape is: turn your data into a list of Request objects, then hand the list to batches.create.
client = anthropic.Anthropic()builds the client; it reads your key from the environment, so the key never appears in the code.- Each
Request(...)wraps two things: acustom_id— your own label for this item (here the ticket id) — and aparamsblock that is just a normal, non-streaming Messages request (model,max_tokens,system,messages). Nothing about the request is batch-specific. - The list comprehension builds one
Requestper ticket. A cheap, fast model (claude-haiku-4-5) is a good fit because one-word classification is easy work. client.messages.batches.create(requests=requests)submits the whole list in a single call and returns a batch object. You immediately savebatch.id— it's the only way to find this job again.
What the output means: Two lines print: the new batch id (starting msgbatch_) and the initial processing_status, which is "in_progress" — the work hasn't started coming back yet.
Try this: Change the three tickets to your own text and re-run (with a real key). The id you get back is what you'll poll and retrieve with in the next two recipes.
custom_id you set. Make it unique within the batch and meaningful to your system.4 · Recipe 2 — poll until the batch ends intermediate
After submitting you have a batch id but no answers yet. You poll: call retrieve(id) on a loop and check processing_status. When it flips to "ended", processing is finished (whether every item succeeded, errored, or expired — "ended" just means the batch is done being worked on).
poll.py# needs: pip install anthropic + ANTHROPIC_API_KEY; makes real API calls
import time
import anthropic
client = anthropic.Anthropic()
while True:
batch = client.messages.batches.retrieve("msgbatch_01AbC...") # the id from submit
if batch.processing_status == "ended":
break
# request_counts breaks the batch down while it runs
counts = batch.request_counts
print("still working:", counts.processing,
"| done:", counts.succeeded, "| errored:", counts.errored)
time.sleep(30) # be patient — most batches finish within an hour, max 24h
print("batch ended")
print("succeeded:", batch.request_counts.succeeded)
print("errored: ", batch.request_counts.errored)
After submitting you have an id but no answers. This real SDK loop polls — it repeatedly asks Anthropic "are you done yet?" until the batch is finished. There is no push notification; asking on a gentle loop is how you find out.
batches.retrieve("msgbatch_...")fetches the current state of the batch you submitted. Paste in the real id from the submit step.- The loop breaks the moment
batch.processing_status == "ended". "ended" means Anthropic has finished working the batch — not that every item succeeded (some may have errored or expired); it just means the results are ready to read. - While it's still running,
batch.request_countsbreaks the batch down live — how many are stillprocessing, how manysucceeded, how manyerrored— so you can show progress. time.sleep(30)is the important manners: batches can take up to an hour, so sleep between polls. A tight loop would just burn requests against your rate limit for nothing.
What the output means: While running, you'll see progress lines (processing / done / errored counts) every 30 seconds; once it ends, the final succeeded and errored totals print.
Try this: Raise the sleep to 60 seconds for a big job. The loop is deliberately patient — polling is how you wait for background work without a callback.
| processing_status | Meaning |
|---|---|
in_progress | Anthropic is still working through the batch. |
canceling | You called .cancel(id); it's winding down. |
ended | Done being processed. Results are ready to retrieve. |
retrieve in a tight loop; sleep between polls (30–60s is plenty for a job that may take an hour). Hammering wastes requests against your rate limit and buys you nothing.5 · Recipe 3 — retrieve results & match by custom_id intermediate
Once the batch has ended, stream the results with .results(id). Each result carries the custom_id you set and a result whose .type tells you what happened. On success, the full Message is at result.result.message — the same object a normal call returns, so you read .content the same way. Build a dict keyed by custom_id so the arbitrary arrival order stops mattering.
results.py# needs: pip install anthropic + ANTHROPIC_API_KEY; makes real API calls
import anthropic
client = anthropic.Anthropic()
answers = {}
for result in client.messages.batches.results("msgbatch_01AbC..."):
cid = result.custom_id # the label you chose at submit time
kind = result.result.type # succeeded | errored | canceled | expired
if kind == "succeeded":
msg = result.result.message # a normal Message object
text = next((b.text for b in msg.content if b.type == "text"), "")
answers[cid] = text
elif kind == "errored":
# per-item failure — the rest of the batch is unaffected
answers[cid] = f"ERROR: {result.result.error.type}"
elif kind == "expired":
answers[cid] = "EXPIRED — resubmit this one"
elif kind == "canceled":
answers[cid] = "CANCELED"
# arrival order was arbitrary; the dict lines everything back up by id
for cid in sorted(answers):
print(cid, "->", answers[cid])
ticket-8801 -> billing
ticket-8802 -> howto
ticket-8803 -> bug
The batch has ended, so this real SDK block collects the answers. The key idea: results arrive in arbitrary order, so you don't trust their position — you key everything by the custom_id you set at submit time and build a dictionary.
batches.results(id)streams the results one at a time. You loop over them instead of getting a single list, which scales to 100,000 items without loading them all at once.- For each result,
result.custom_idis your label andresult.result.typetells you the outcome — one ofsucceeded,errored,canceled, orexpired. - On
succeeded,result.result.messageis an ordinary Message object, so you pull the text out with the same guard-the-blocks pattern as a normal call (next((b.text for b in msg.content if b.type == "text"), "")). - The other branches record why an item didn't succeed instead of crashing. Because you store everything in
answers[cid], the arbitrary arrival order stops mattering — the dict re-links every answer to its request.
What the output means: The three tickets print in id order — ticket-8801 -> billing, ticket-8802 -> howto, ticket-8803 -> bug — even though they may have come back in any order.
Try this: Make one request deliberately malformed and re-run. That single item comes back errored in the loop while the others still succeed — one bad request doesn't sink the batch.
ended can still contain individual items that errored or expired. That per-item granularity is a feature: one malformed request doesn't sink the other 99,999. Always branch on result.result.type — never assume every result is a success.6 · Error handling per item advanced
In the synchronous API a bad request raises an exception and you deal with it right there. In a batch there is no single call to wrap in try/except — instead, each item carries its own outcome. The four result.result.type values map to four different reactions:
| result.result.type | What it means | What to do |
|---|---|---|
succeeded | Item completed normally. | Read result.result.message. |
errored | This request failed (e.g. invalid_request, or a server error). | Inspect result.result.error. Fix & resubmit if it's your request; safe to retry if it's a server error. |
expired | The 24-hour processing window elapsed before this item ran. | Resubmit it in a new batch. |
canceled | You canceled the batch before this item ran. | Resubmit if you still want it. |
The practical rule: treat the results stream as a reconciliation step. Walk every item, route successes to your output, and collect the errored / expired custom_ids into a "retry" list you can feed straight into the next batch.
7 · Batch vs realtime vs streaming — when to use which advanced
The API gives you three delivery modes for the same underlying model. Choosing correctly is a latency-vs-cost decision:
| Mode | Latency | Cost | Use when |
|---|---|---|---|
Synchronous create() | ~1 request, seconds | full price | A human is waiting on this answer (chat, an API endpoint). |
Streaming stream() | first tokens in ~ms | full price | A human is waiting and you want the reply to appear as it's written (chat UIs, long outputs). |
Batch batches.create() | minutes to hours | ~50% price | No human is waiting on any single item; you have many independent requests (bulk classify, summarize, label, backfill). |
A simple test: is anyone blocked on this exact response? If yes → synchronous or streaming. If the work can happen overnight or "sometime in the next hour" → batch, and take the discount. Streaming and batching solve opposite problems — streaming minimizes perceived latency for one interactive request; batching maximizes throughput per dollar for many non-interactive ones. Don't reach for a batch just to save money on a latency-sensitive path.
8 · Cost math (this one runs offline) professional
Before you commit a big job to a batch, it's worth doing the arithmetic: how much does the ~50% discount actually save, and is the added latency worth it? The helper below is pure stdlib and runs with a plain python file.py — no API key, no network. It compares the realtime cost of N requests against the batch cost.
cost.pydef batch_savings(num_requests, in_tokens, out_tokens,
in_price_per_mtok, out_price_per_mtok, batch_discount=0.50):
"""Compare realtime vs batch cost for a bulk job. Prices are $ per 1M tokens.
batch_discount=0.50 means batch pays 50% of the realtime per-token price."""
per_request = (in_tokens * in_price_per_mtok
+ out_tokens * out_price_per_mtok) / 1_000_000
realtime = per_request * num_requests
batch = realtime * (1 - batch_discount)
return {
"realtime_usd": round(realtime, 2),
"batch_usd": round(batch, 2),
"saved_usd": round(realtime - batch, 2),
"saved_pct": round(100 * (realtime - batch) / realtime),
}
# 50,000 classification calls on Haiku ($1 in / $5 out per 1M tokens)
result = batch_savings(
num_requests=50_000, in_tokens=400, out_tokens=8,
in_price_per_mtok=1.00, out_price_per_mtok=5.00,
)
for k, v in result.items():
print(f"{k}: {v}")
realtime_usd: 22.0
batch_usd: 11.0
saved_usd: 11.0
saved_pct: 50
Unlike the three blocks above, this one is pure Python — no key, no network — so it runs with a plain python cost.py. It answers the question you should ask before every big job: how much does the ~50% batch discount actually save here?
batch_savings(...)takes the job size (num_requests), the per-request token counts, and your model's prices (dollars per million tokens).per_requestis the cost of one call: input tokens times the input price plus output tokens times the output price, divided by 1,000,000 because prices are quoted per million tokens.realtimeis that times the number of requests;batchmultiplies by(1 - batch_discount)— with the default 0.50, batch pays half.- It returns the two totals plus the dollars and percent saved, so you can see the trade in concrete money before committing the workload to the slower path.
What the output means: For 50,000 Haiku classification calls: realtime_usd: 22.0, batch_usd: 11.0, saved_usd: 11.0, saved_pct: 50 — half the cost, for work that can wait up to an hour.
Try this: Change num_requests to your real volume and swap in your model's prices. The saving scales linearly with job size — the real decision is whether the latency is acceptable, not whether it's cheaper.
9 · Tech-lead — production batch pipelines tech-lead
A lead owns the batch as a pipeline, not a one-off call. The scale limits set the shape: a single batch holds up to 100,000 requests or 256 MB, processes within 24 hours, and keeps results available for 29 days. Design around those numbers:
Production batch checklist
- Idempotency via
custom_id. Makecustom_ida stable key from your own data (a row ID, a content hash) — not a random UUID. Then a resubmit after a crash overwrites cleanly instead of duplicating work, and reconciliation is a simple keyed merge. - Chunk large jobs. A 500k-item run won't fit in one batch — split into chunks under the 100k / 256MB limits and track each chunk's batch
id. Chunking also bounds blast radius: one bad chunk fails alone. - Persist the batch id immediately. The
idis your only handle. Write it to a durable store the momentcreate()returns, before you start polling — if your poller dies, you can resume from the id. - Reconcile every item. Walk the full results stream, route
succeededto output, and collecterrored/expiredcustom_ids into a retry batch. Never assume 100% success. - Monitor
request_counts. Alert on risingerroredcounts — a spike usually means a bug in how you built the requests, caught early before you burn the whole job. - Lean on prompt caching + a cheap model. A shared system prompt across all items caches well; classification-shaped work runs fine on
claude-haiku-4-5. Stack those on top of the 50% batch discount for the lowest cost per item.
The mental model: a batch pipeline is an at-least-once job queue you don't have to run. Anthropic owns the workers and the scheduling; your job is to build clean requests with stable ids, persist the handle, and reconcile the outcomes. Get idempotency and reconciliation right and the pipeline survives crashes, partial failures, and reruns without duplicating spend or losing results.
Exercise AP1.1 — Estimate then decide
Context: The batch-vs-synchronous choice is driven by the latency requirement, not just the price — the real question is whether anyone is blocked waiting on any single result. Grounding the decision in your own numbers is what makes it defensible.
Your task: Take a real (or invented) bulk task, use cost.py with realistic token counts to compute realtime versus batch cost and dollars saved, then decide batch or synchronous and justify it.
Requirements:
- Use
cost.pywith realistic token counts and your model's prices - Report the realtime cost, the batch cost, and the dollars saved
- Answer the gate question: is anyone blocked on any single summary?
- Justify the batch-vs-synchronous choice from the latency requirement, not only the price
💡 Hint: If no user is waiting on an individual result, the latency the batch trades away costs you nothing — that is the case where the 50% discount is free money.
Exercise AP1.2 — Design the reconciliation
Context: At hundreds of thousands of items a batch pipeline has to chunk under the request limit and survive partial failure without double-processing. Designing the reconciliation up front is what keeps large runs idempotent.
Your task: Sketch (in pseudocode or the real SDK) a pipeline for 250,000 items covering chunking, custom_id choice, persistence, retry construction, and monitoring.
Requirements:
- Chunk the 250k items under the 100k-per-batch limit
- Choose a
custom_idscheme that makes resubmits idempotent - Persist each chunk's batch
idso runs can resume - Build the retry batch from
erroredandexpiredresults - State what your monitoring alerts on
💡 Hint: Let custom_id come from your source-of-truth key so a resubmit under the same id fills a gap instead of creating a duplicate.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: The Message Batches API bills every token at 50% of standard prices, so the batch-vs-realtime call is a routine cost decision on any bulk workload. Knowing the dollar delta before you commit is what lets you defend the choice.
Your task: Write batch_cost(in_tok, out_tok) for Opus 4.8 ($5/$25 per MTok) and report the saving versus realtime for 2M input + 500K output tokens.
Requirements:
- Pure arithmetic — no API key or network call
- Price input and output separately at $5 and $25 per million tokens
- The batch figure is exactly half the realtime figure (both tokens discounted)
- Print realtime cost, batch cost, and dollars saved
- Name the tradeoff you accept for the discount (latency, not price)
💡 Hint: Compute the realtime cost first, then the batch cost is just that × 0.5 — the discount applies uniformly to input and output.
Show solution
Pure arithmetic — no API key needed:
IN, OUT = 5.0, 25.0 # $ per million tokens, Opus 4.8
def realtime_cost(in_tok, out_tok):
return in_tok/1e6*IN + out_tok/1e6*OUT
def batch_cost(in_tok, out_tok):
return realtime_cost(in_tok, out_tok) * 0.5 # Batches = 50% off
i, o = 2_000_000, 500_000
print(f"realtime: ${realtime_cost(i,o):.2f}") # $22.50
print(f"batch : ${batch_cost(i,o):.2f}") # $11.25
print(f"saved : ${realtime_cost(i,o)-batch_cost(i,o):.2f}") # $11.25
Every token — input and output — is half price in a batch. The tradeoff is latency: results land within the hour typically, up to 24h, not in seconds.
Context: A batch's results come back in any order, so position tells you nothing. The custom_id you attach to each request is the only stable handle back to the input it answered.
Your task: Build a batch of three classification requests with distinct custom_ids, submit it, and collect the results into a dict keyed by custom_id.
Requirements:
- Needs a real API key to run
- Use the Batches API via
client.messages.batches.create - Each request wraps
MessageCreateParamsNonStreamingin aRequestwith a uniquecustom_id - Results are gathered into a dict keyed by
custom_id, never by index - Print the returned batch id and its initial processing status
💡 Hint: The SDK shape is Request(custom_id=..., params=MessageCreateParamsNonStreaming(...)); the id is what survives the out-of-order return.
Show solution
Correct SDK shape — Request + MessageCreateParamsNonStreaming:
import anthropic
from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
from anthropic.types.messages.batch_create_params import Request
client = anthropic.Anthropic()
texts = ["Great product!", "Awful service.", "It was fine."]
batch = client.messages.batches.create(requests=[
Request(
custom_id=f"item-{i}",
params=MessageCreateParamsNonStreaming(
model="claude-haiku-4-5", max_tokens=16,
messages=[{"role": "user",
"content": f"Sentiment (positive/negative/neutral), one word: {t}"}],
),
)
for i, t in enumerate(texts)
])
print(batch.id, batch.processing_status) # msgbatch_..., "in_progress"
Results arrive in any order — always key by custom_id, never by position. That is the whole point of the id.
Context: A batch that reaches ended is not the same as "everything succeeded" — each item carries its own outcome. Production code has to poll for completion and then branch on every possible per-item result.
Your task: Poll retrieve until processing_status == "ended", then stream results() and branch on each result.type.
Requirements:
- Needs a real API key to run
- Poll with a sleep between checks rather than a tight loop
- Handle all four result types: succeeded, errored, expired, canceled
- On success, extract the text from the returned message content
- On error, surface
error.typeso retry-vs-fix is decidable - Collect every item's outcome keyed by
custom_id
💡 Hint: Loop on retrieve then iterate results(); a match on result.type keeps the four branches readable.
Show solution
The full lifecycle, with per-item result handling:
import time
while True:
b = client.messages.batches.retrieve(batch.id)
if b.processing_status == "ended":
break
time.sleep(30) # most batches finish within the hour
out = {}
for r in client.messages.batches.results(batch.id):
match r.result.type:
case "succeeded":
msg = r.result.message
out[r.custom_id] = next((b.text for b in msg.content if b.type == "text"), "")
case "errored":
# invalid_request -> fix & resubmit; server errors -> safe to retry
out[r.custom_id] = f"ERR:{r.result.error.type}"
case "expired" | "canceled":
out[r.custom_id] = r.result.type
print(out)
A batch that ended is not "all succeeded" — each item carries its own result type. Errored items report error.type so you know whether to fix-and-resubmit or just retry.
Context: When every item in a batch analyzes the same large document, re-sending that document per item is pure waste. A cached shared prefix makes the batch pay for the big block once and read it cheaply for the rest.
Your task: Put the shared document in a system prompt with a cache_control breakpoint so the batch writes the cache once, and explain how caching interacts with the 50% batch discount.
Requirements:
- Needs a real API key to run
- The large shared text is the last system block carrying
cache_control: {"type": "ephemeral"} - Only the per-item question varies; it sits after the cached prefix
- The document appears before the last cache breakpoint so items don't each write their own entry
- Explain that the discount and cache stack: write once at the discounted write rate, then reads at ~0.1x
💡 Hint: Keep everything stable and shared above the last cache_control block, and put the varying question below it.
Show solution
Cache the shared prefix; vary only the per-item question:
shared_system = [
{"type": "text", "text": "You are a contract analyst."},
{"type": "text", "text": LARGE_CONTRACT_TEXT,
"cache_control": {"type": "ephemeral"}}, # cache the big shared block
]
questions = ["Termination clause?", "Payment schedule?", "Governing law?"]
batch = client.messages.batches.create(requests=[
Request(custom_id=f"q-{i}",
params=MessageCreateParamsNonStreaming(
model="claude-opus-4-8", max_tokens=512,
system=shared_system,
messages=[{"role": "user", "content": q}]))
for i, q in enumerate(questions)
])
Caching and the batch discount stack: the shared prefix bills once at the (discounted) cache-write rate and is read at ~0.1x for the rest. Keep the document before the last cache_control block and the per-item question after it, or every item writes its own cache entry.
Context: A production batch pipeline must be safe to re-run without duplicating completed work. Because custom_id is your join key, resubmitting the failures under the same ids is what makes a re-run idempotent.
Your task: Given a results iterator, resubmit only the errored/expired items while preserving their original custom_ids.
Requirements:
- Needs a real API key to run
- Separate terminal successes from retryable failures in one pass
- Permanent
invalid_requesterrors are logged, not blindly retried - Server errors and expirations are treated as retryable
- The retry batch reuses each item's original
custom_idso downstream joins stay stable - A re-run fills only the gaps and never duplicates completed work
💡 Hint: Partition results into an ok map and a retry list, then rebuild requests for the retry ids from your source-of-truth store.
Show solution
Separate terminal successes from retryable failures, resubmit the latter:
def collect(batch_id):
ok, retry = {}, []
for r in client.messages.batches.results(batch_id):
if r.result.type == "succeeded":
ok[r.custom_id] = r.result.message
elif r.result.type == "errored" and r.result.error.type == "invalid_request":
pass # permanent — do NOT blindly retry; log it
else: # errored(server) / expired -> retryable
retry.append(r.custom_id)
return ok, retry
# Rebuild requests for the retry ids from your source-of-truth store, keeping ids:
def resubmit(retry_ids, source):
if not retry_ids:
return None
return client.messages.batches.create(requests=[
Request(custom_id=cid, params=source[cid]) for cid in retry_ids])
Because custom_id is your join key, resubmitting with the same ids keeps the pipeline idempotent: a re-run fills the gaps without duplicating completed work, and permanent invalid_request errors are logged rather than retried forever.
Context: A platform owner routes wildly different workloads — interactive chat, nightly bulk enrichment, a live coding assistant — through the same model. Picking the right delivery mode per workload is a recurring architectural call.
Your task: Write a selector that recommends batch, realtime, or streaming for each of three workloads and states the reasoning behind each choice.
Requirements:
- Pure decision logic — no API key needed
- Decide on three axes: latency-sensitivity, volume, and output length
- Latency-sensitive + long output routes to streaming (progress, no timeout)
- Latency-sensitive + short routes to realtime
messages.create - High-volume offline work routes to batch for the 50% discount
- Each recommendation is returned with a one-line justification
💡 Hint: Encode the tradeoffs as guards in priority order: streaming and realtime handle the interactive cases, batch is the default for large async volume.
Show solution
Encode the decision the lesson draws (pure logic, no API needed):
def choose_mode(latency_sensitive, volume, long_output):
if latency_sensitive and long_output:
return "streaming — token-by-token so the user sees progress, no HTTP timeout"
if latency_sensitive:
return "realtime (messages.create) — sub-second single response"
if volume >= 1000:
return "batch — 50% cheaper, async, up to 24h; ideal for bulk offline jobs"
return "realtime — small volume, not worth batch orchestration"
print(choose_mode(True, 1, True)) # coding assistant -> streaming
print(choose_mode(True, 1, False)) # chat turn -> realtime
print(choose_mode(False, 50000, True)) # nightly enrichment-> batch
The axes are latency-tolerance, volume, and output length. Batch trades latency for half-price bulk throughput; streaming trades nothing but adds progress + timeout safety on long outputs; realtime is the default for interactive, low-volume calls.
✓ Checkpoint — you can move on when you can…
- Explain the batch lifecycle: build → submit → poll → retrieve, at ~50% cost.
- Write the three real SDK calls:
batches.create,.retrieve,.results. - Say why every request needs a
custom_idand match results back by it. - Branch correctly on
result.result.type(succeeded / errored / expired / canceled). - Decide batch vs synchronous vs streaming from the latency requirement.
- Design an idempotent, chunked, reconciled production batch pipeline.
Knowledge check check yourself
Why is custom_id the critical field on a batch request, and what breaks if you don't set it meaningfully?
Show answer
custom_id is the only way to link each result back to its originating request. If it isn't unique and meaningful (e.g. a ticket ID or row key), you can't reconcile which output belongs to which input.What does the Message Batches API trade for its roughly 50% discount, and when should you NOT use it?