AI EngineeringZero to ProductionHome·About·Contact
Anthropic API in Practice · Part 1

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.

⏱️ ~1.5 hours🧪 5 recipes🎯 Beginner→Tech-lead

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_id on every item.
  • Submit with client.messages.batches.create(...) and poll processing_status.
  • Retrieve results with .results(id) and match them back by custom_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.

Same requests, different deliveryA batch request is not a special kind of request — it's a normal Messages API call moved onto a cheaper, slower conveyor belt. Everything you know (system prompts, tools, vision, prompt caching, structured output) works inside a batch.

2 · The batch lifecycle essential

Every batch goes through the same four beats. Learn this shape once and the code below is just wiring:

Build requests custom_id each Submit batch → batch id Poll status until 'ended' Retrieve results match by id
🗺️ How to read this diagram

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_id label 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 an id. 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 watch processing_status until 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.

Why ~50% cheaper?Async gives Anthropic scheduling freedom — your work can be packed into spare capacity instead of served the instant you ask. You pay for that flexibility with latency (minutes to hours, not milliseconds), and Anthropic passes the efficiency back as a 50% discount on all batch token usage.

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.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Python · anthropic SDK — create a batch ▶ needs API key + network
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"
▶ How this works

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.

  1. client = anthropic.Anthropic() builds the client; it reads your key from the environment, so the key never appears in the code.
  2. Each Request(...) wraps two things: a custom_id — your own label for this item (here the ticket id) — and a params block that is just a normal, non-streaming Messages request (model, max_tokens, system, messages). Nothing about the request is batch-specific.
  3. The list comprehension builds one Request per ticket. A cheap, fast model (claude-haiku-4-5) is a good fit because one-word classification is easy work.
  4. client.messages.batches.create(requests=requests) submits the whole list in a single call and returns a batch object. You immediately save batch.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 is the whole trickResults come back in arbitrary order, and there is no positional guarantee. The only reliable way to know which answer belongs to which request is the 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).

Python · anthropic SDK — poll processing_status ▶ needs API key + network
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)
▶ How this works

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.

  1. batches.retrieve("msgbatch_...") fetches the current state of the batch you submitted. Paste in the real id from the submit step.
  2. 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.
  3. While it's still running, batch.request_counts breaks the batch down live — how many are still processing, how many succeeded, how many errored — so you can show progress.
  4. 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_statusMeaning
in_progressAnthropic is still working through the batch.
cancelingYou called .cancel(id); it's winding down.
endedDone being processed. Results are ready to retrieve.
Poll gentlyThere is no push notification — you have to ask. Don't hammer 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.

Python · anthropic SDK — collect results by custom_id ▶ needs API key + network
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
▶ How this works

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.

  1. 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.
  2. For each result, result.custom_id is your label and result.result.type tells you the outcome — one of succeeded, errored, canceled, or expired.
  3. On succeeded, result.result.message is 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"), "")).
  4. 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" is not "all succeeded"A batch that 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.typeWhat it meansWhat to do
succeededItem completed normally.Read result.result.message.
erroredThis 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.
expiredThe 24-hour processing window elapsed before this item ran.Resubmit it in a new batch.
canceledYou 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.

Errors are data, not exceptionsBecause failures arrive as typed result objects rather than raised exceptions, batch error handling is just a branch in a loop. That makes a 100k-item job robust: you get a clean list of exactly which items need attention, with no crash and no lost successes.

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:

ModeLatencyCostUse when
Synchronous create()~1 request, secondsfull priceA human is waiting on this answer (chat, an API endpoint).
Streaming stream()first tokens in ~msfull priceA 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% priceNo 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.

Python · batch-vs-realtime cost calculator (runs offline)
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
▶ How this works

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?

  1. batch_savings(...) takes the job size (num_requests), the per-request token counts, and your model's prices (dollars per million tokens).
  2. per_request is 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.
  3. realtime is that times the number of requests; batch multiplies by (1 - batch_discount) — with the default 0.50, batch pays half.
  4. 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.

The discount scales with volumeAt 50k requests the absolute saving is real money, and it grows linearly with job size. The break-even question is never "is batch cheaper?" (it always is per token) — it's "can this workload tolerate up-to-an-hour latency?" If yes, batching is close to free money.

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

  1. Idempotency via custom_id. Make custom_id a 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.
  2. 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.
  3. Persist the batch id immediately. The id is your only handle. Write it to a durable store the moment create() returns, before you start polling — if your poller dies, you can resume from the id.
  4. Reconcile every item. Walk the full results stream, route succeeded to output, and collect errored / expired custom_ids into a retry batch. Never assume 100% success.
  5. Monitor request_counts. Alert on rising errored counts — a spike usually means a bug in how you built the requests, caught early before you burn the whole job.
  6. 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.

Results expire — collect themBatch results are retained for 29 days after creation, then they're gone. For a long-running or scheduled pipeline, build result-collection into the job itself; don't assume you can go back and fetch a two-month-old batch's output.

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.py with 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_id scheme that makes resubmits idempotent
  • Persist each chunk's batch id so runs can resume
  • Build the retry batch from errored and expired results
  • 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.

Exercise 1 · Batch cost vs realtime (OFFLINE calculator)Beginner

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.

Exercise 2 · Submit a batch and key results by custom_idIntermediate

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 MessageCreateParamsNonStreaming in a Request with a unique custom_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.

Exercise 3 · Poll to completion, then handle each result typeAdvanced

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.type so 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.

Exercise 4 · Share a cached prefix across every batch itemExpert

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.

Exercise 5 · Idempotent resubmission of only the failed itemsProfessional

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_request errors are logged, not blindly retried
  • Server errors and expirations are treated as retryable
  • The retry batch reuses each item's original custom_id so 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.

Exercise 6 · Choose batch vs realtime vs streaming as the platform ownerIndustry scenario

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_id and 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

✓ Knowledge check

Why is custom_id the critical field on a batch request, and what breaks if you don't set it meaningfully?

Show answer
Batch results come back in arbitrary order with no positional guarantee, so 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.
✓ Knowledge check

What does the Message Batches API trade for its roughly 50% discount, and when should you NOT use it?

Show answer
It trades immediacy: results aren't instant (typically within an hour, capped at 24 hours) in exchange for tokens billed at about half price. You should not batch latency-sensitive work where a human is waiting on any single item (chat, an API endpoint); use synchronous or streaming calls there instead.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in