DEV Community

galian for Cursuri AI

Posted on

The Batch API Halves Your Claude Bill — Unless Your Prompt Cache Was Already Doing the Work

The Message Batches API has the simplest pitch in Anthropic's pricing table: send work that doesn't need an answer right now, get the results back later (most batches finish within an hour, the hard limit is 24), and pay half of every token. For plenty of workloads it is the easiest cost cut you will ever ship.

For one very common workload it isn't a cut at all. Picture a job that sends a large shared prefix (instructions, a label taxonomy, few-shot examples, a policy document), a small per-item payload, and asks for a short answer. If you already run that job synchronously with prompt caching, most of your bill is cache reads. Batch halves those too. But inside a batch, cache hits are best-effort, and Anthropic's documentation puts typical hit rates anywhere between 30% and 98%. Every miss is billed as a cache write, which costs 25 times a cache read on Opus 5.5. Move that job to batch at the wrong hit rate and it costs several times what it cost synchronously.

What follows applies the cost-versus-reliability reasoning from the production LLM integration course at Cursuri-AI.ro to this one API: the arithmetic on current list prices, a function that makes the call, the documented recipe for raising batch hit rates, and the operational details that decide whether you lose data (idempotent custom_ids, unordered results, the four result types, re-submitting only what failed, the 24-hour and 29-day clocks).

Prompt caching itself (prefix matching, breakpoints, silent invalidators) is assumed knowledge here.

What "50% off" actually applies to

The discount covers all usage: input, output and, because the caching multipliers stack with the Batch API discount, cache writes and cache reads as well. For the three current models, in USD per million tokens, synchronous → batch:

Model Input 5-min cache write 1-hour cache write Cache read Output
Claude Fable 5.1 $10 → $5 $12.50 → $6.25 $20 → $10 $0.25 → $0.125 $50 → $25
Claude Opus 5.5 $4 → $2 $5 → $2.50 $8 → $4 $0.20 → $0.10 $20 → $10
Claude Sonnet 5.5 $2 → $1 $2.50 → $1.25 $4 → $2 $0.20 → $0.10 $10 → $5

Two things in that table matter more than the headline.

First, the batch input price is far above the synchronous cache-read price: ten times on Opus 5.5 ($2 vs $0.20), five times on Sonnet 5.5, twenty times on Fable 5.1. Halving a token you were already reading from cache at a few percent of list price saves almost nothing in absolute terms. Halving it and then losing the cache hit costs you a great deal.

Second, the read multiplier differs across the lineup. Fable 5.1 reads cache at 0.025× its input price, Opus 5.5 at 0.05×, Sonnet 5.5 at the standard 0.1×. The cheaper the read, the less of your bill is left for the batch discount to work on, and the more a miss hurts by comparison.

A worked example: 10,000 classifications against a 50K prefix

Take a hypothetical but typical backfill on Claude Opus 5.5 (Anthropic's recommended starting model for most workloads):

  • Shared prefix: 50,000 tokens of instructions, label definitions and examples, identical on every request.
  • Per item: 500 tokens of fresh input (the ticket, the document, the record).
  • Output: 150 tokens. On Opus 5.5 adaptive thinking is always on, and thinking tokens are billed as output, so treat this as "measured output including thinking at low effort", not as the length of the JSON you get back.

Run synchronously with a steady request rate, the prefix is written once and then read on nearly every call. Per request: $0.010 for the cached prefix, $0.002 for the fresh input, $0.003 for the output, so $0.015. Here is the same job on batch, for all 10,000 items:

Setup (Opus 5.5, 10,000 requests) Cost
Synchronous, prompt caching, ≈100% hits $150
Batch, no prompt caching $1,025
Batch, caching, 98% hit rate $99
Batch, caching, 95% hit rate $135
Batch, caching, 90% hit rate $195
Batch, caching, 60% hit rate $555
Batch, caching, 30% hit rate $915

These figures use 5-minute writes for the misses. With 1-hour writes a miss costs more ($4 instead of $2.50 per million batch tokens), so 90% becomes $270 and 60% becomes $855.

Read the table from the bottom. Batch without caching costs almost seven times the cached synchronous job; batch with caching at the low end of the documented range, six times. Batch wins only near the top of that range, and the gap between winning and losing badly is a few points of hit rate you don't directly control. You may well land high, and the recipe below pushes you that way, but you won't know until you measure.

The break-even, for any workload

The comparison generalizes. Let P be the shared prefix in tokens, r the synchronous cache-read price, w the synchronous cache-write price for the TTL you use in the batch, and U the uncacheable part of each request, priced the same way (fresh tokens × input price + output tokens × output price). If your synchronous job already hits the cache on essentially every request, batch is cheaper only when its hit rate h satisfies:

h > (w - 2r - U/P) / (w - r)
Enter fullscreen mode Exit fullscreen mode

For the example shape (50K prefix, 500 fresh tokens, 150 output tokens):

Model Break-even hit rate, 5-min writes Break-even hit rate, 1-hour writes
Claude Fable 5.1 95.9% 97.5%
Claude Opus 5.5 93.8% 96.2%
Claude Sonnet 5.5 89.1% 93.4%

The threshold moves fast with output size, because output is the part caching can't touch and batch always halves:

Opus 5.5, 50K prefix, 5-min writes Break-even hit rate
150 output tokens 93.8%
1,000 output tokens 86.7%
4,000 output tokens 61.7%
8,000 output tokens 28.3%
above ~11,400 output tokens none: batch always wins

And it moves the other way with prefix size: with 150 output tokens on Opus 5.5, a 2K prefix needs about 44%, a 5K prefix 75%, a 20K prefix about 91%, a 200K prefix about 95%.

Batch's advantage grows with everything that can't be cached (long answers, heavy thinking, large per-item inputs) and shrinks as the shared prefix grows. Short answers over a huge shared context are exactly where synchronous caching is hardest to beat. Long generations over a modest prompt are where batch is a clean 50% cut.

Here is the decision as code. Prices are synchronous list prices; the function applies the discount itself:

# Synchronous list prices, USD per million tokens (Anthropic pricing page, 2 Oct 2026)
PRICES = {
    "claude-fable-5-1":  {"input": 10.00, "write_5m": 12.50, "write_1h": 20.00, "read": 0.25, "output": 50.00},
    "claude-opus-5-5":   {"input": 4.00,  "write_5m": 5.00,  "write_1h": 8.00,  "read": 0.20, "output": 20.00},
    "claude-sonnet-5-5": {"input": 2.00,  "write_5m": 2.50,  "write_1h": 4.00,  "read": 0.20, "output": 10.00},
}
BATCH_DISCOUNT = 0.5  # applies to every column above, cache reads and writes included
MIN_CACHEABLE = 512   # tokens, for all three models above


def cost_per_request(model: str, prefix: int, fresh: int, output: int,
                     hit_rate: float, ttl: str = "5m", batch: bool = False) -> float:
    """Expected USD per request. A miss on a cache_control prefix is billed as a cache write."""
    p = PRICES[model]
    write = p["write_1h"] if ttl == "1h" else p["write_5m"]
    prefix_rate = hit_rate * p["read"] + (1 - hit_rate) * write
    usd = (prefix * prefix_rate + fresh * p["input"] + output * p["output"]) / 1_000_000
    return usd * BATCH_DISCOUNT if batch else usd


def breakeven_hit_rate(model: str, prefix: int, fresh: int, output: int,
                       sync_hit_rate: float = 1.0, ttl: str = "1h") -> float:
    """Lowest batch cache hit rate at which batch beats your current synchronous job."""
    p = PRICES[model]
    write = p["write_1h"] if ttl == "1h" else p["write_5m"]
    sync = cost_per_request(model, prefix, fresh, output, sync_hit_rate)
    uncacheable = (fresh * p["input"] + output * p["output"]) / 1_000_000
    needed_prefix_rate = (sync / BATCH_DISCOUNT - uncacheable) * 1_000_000 / prefix
    return (write - needed_prefix_rate) / (write - p["read"])


def should_batch(model: str, prefix: int, fresh: int, output: int, *,
                 sync_hit_rate: float = 1.0, measured_batch_hit_rate: float | None = None,
                 ttl: str = "1h", hard_deadline_under_24h: bool = False,
                 zdr_or_hipaa: bool = False) -> tuple[bool | None, str]:
    if zdr_or_hipaa:
        return False, "Batch is not ZDR- or HIPAA-eligible: inputs and outputs are stored up to 29 days."
    if hard_deadline_under_24h:
        return False, "A batch can end with requests expired, unprocessed, after 24 hours."
    if prefix < MIN_CACHEABLE:
        return True, "Nothing here can be cached, so batch is a flat 50% cut."
    need = breakeven_hit_rate(model, prefix, fresh, output, sync_hit_rate, ttl)
    if need <= 0:
        return True, "Uncacheable tokens dominate: batch wins even with zero cache hits."
    if measured_batch_hit_rate is None:
        return None, f"Run a pilot batch first: you need a cache hit rate above {need:.1%}."
    if measured_batch_hit_rate > need:
        return True, f"Measured {measured_batch_hit_rate:.1%} beats break-even {need:.1%}."
    return False, f"Measured {measured_batch_hit_rate:.1%} is below break-even {need:.1%}: stay synchronous."
Enter fullscreen mode Exit fullscreen mode

sync_hit_rate is there because your synchronous job may not be the ideal baseline either. A job that fans out hundreds of parallel requests can't read a cache entry until the first response that wrote it has started, so it pays for many writes at the start; plug in what your logs show. And price isn't the only input. Batch traffic has its own rate limits and doesn't count against your Messages API limits, so a backfill on batch doesn't compete with production traffic. If your synchronous backfill is throttling user-facing requests, that can justify batch at break-even.

Raising the batch hit rate: the documented recipe

The prompt-caching docs describe a specific pattern for batches. Each step below closes one way a batch request can miss.

Put an explicit breakpoint at the end of the shared prefix. Don't rely on automatic caching here. It places the breakpoint on the last cacheable block, which in a batch is the per-item content: every request writes an entry no other request will ever read.

Make every request render the same prefix, byte for byte. Same tools, same system, and also the same thinking configuration, output_config.effort and output_config.format. Those settings are rendered into the prompt, so a request at a different effort or with a different schema has a different prefix, even when the text is identical.

Use the 1-hour TTL. The docs note that batch requests commonly take between 5 minutes and an hour to complete, which is longer than the default 5-minute cache lifetime. The 1-hour write costs more, and the break-even function accounts for it.

Prime the cache with a one-request batch. Submit a batch containing a single request that carries the shared prefix and the 1-hour cache block. When it ends, submit the rest. The primer can be one of your real items, so nothing is wasted.

Stay above the minimum. The minimum cacheable prompt length is 512 tokens on Fable 5.1, Opus 5.5 and Sonnet 5.5. Below it nothing is cached and no error is returned.

Stay in one workspace. Both batches and prompt caches are isolated per workspace on the Claude API, so a primer written from another workspace's key primes nothing for you.

import anthropic

client = anthropic.Anthropic()
MODEL = "claude-opus-5-5"
# INSTRUCTIONS, TAXONOMY_AND_EXAMPLES and LABEL_SCHEMA are your own constants.

SHARED_SYSTEM = [
    {"type": "text", "text": INSTRUCTIONS},
    {
        "type": "text",
        "text": TAXONOMY_AND_EXAMPLES,                         # the large shared prefix
        "cache_control": {"type": "ephemeral", "ttl": "1h"},   # explicit breakpoint
    },
]
# Effort and output format are rendered into the prompt: keep them identical everywhere.
OUTPUT_CONFIG = {"effort": "low", "format": {"type": "json_schema", "schema": LABEL_SCHEMA}}


def params_for(item_text: str) -> dict:
    return {
        "model": MODEL,
        "max_tokens": 2048,            # thinking counts toward max_tokens
        "system": SHARED_SYSTEM,
        "output_config": OUTPUT_CONFIG,
        "messages": [{"role": "user", "content": item_text}],
    }


def submit_primed(items: list[tuple[str, str]]) -> tuple[str, str]:
    """items: (custom_id, text). One request writes the 1-hour cache, then the rest follow."""
    first_id, first_text = items[0]
    primer = client.messages.batches.create(
        requests=[{"custom_id": first_id, "params": params_for(first_text)}],
    )
    wait_for(primer.id)                # defined in the polling section below
    main = client.messages.batches.create(
        requests=[{"custom_id": cid, "params": params_for(text)} for cid, text in items[1:]],
    )
    return primer.id, main.id
Enter fullscreen mode Exit fullscreen mode

Then measure. Every succeeded result carries the full usage object, so your real hit rate is one division away: cache_read_input_tokens / (cache_read_input_tokens + cache_creation_input_tokens), summed across results. That token-weighted ratio is the h the break-even formula wants. Run a pilot of a few thousand items, compute it, feed it to should_batch(), and only then move the full job.

Running it in production without losing results

The economics decide whether to batch. The operational details decide whether every item comes back exactly once. A batch job has more ways to lose data quietly than a synchronous loop: results arrive out of order, failures are reported per request inside a "successful" batch, and everything has an expiry date.

custom_id is your idempotency key

Each request carries a custom_id of 1 to 64 characters matching ^[a-zA-Z0-9_-]{1,64}$. Results can come back in any order, so the custom_id is the only reliable link between a result and its input. Never zip results against your input list.

The API requires uniqueness only within a batch. Nothing stops you from submitting the same item in two batches and paying for it twice. That makes the custom_id your job, and the useful version is deterministic: derive it from the record's identity and the prompt version, so the same item under the same prompt always maps to the same ID, and a new prompt produces new IDs. Then keep a ledger keyed by it. The same discipline applies to any idempotent data pipeline: the key, not the run, defines what has been processed.

import hashlib
import json
import sqlite3

PROMPT_VERSION = "labels-v3"   # bump on any change to model, prompt, schema or effort

db = sqlite3.connect("batch_ledger.db")
db.execute("""
    CREATE TABLE IF NOT EXISTS ledger (
        custom_id   TEXT PRIMARY KEY,
        record_id   TEXT NOT NULL,
        status      TEXT NOT NULL DEFAULT 'pending',  -- pending|submitting|submitted|succeeded|retry|failed
        batch_id    TEXT,
        attempts    INTEGER NOT NULL DEFAULT 0,
        error       TEXT,
        result_json TEXT
    )
""")


def make_custom_id(record_id: str, prompt_version: str = PROMPT_VERSION) -> str:
    digest = hashlib.sha256(f"{prompt_version}:{record_id}".encode()).hexdigest()
    return f"r_{digest[:40]}"          # 42 chars, only [a-z0-9_]


def enqueue(record_ids: list[str]) -> None:
    db.executemany(
        "INSERT OR IGNORE INTO ledger (custom_id, record_id) VALUES (?, ?)",
        [(make_custom_id(rid), rid) for rid in record_ids],
    )
    db.commit()
Enter fullscreen mode Exit fullscreen mode

INSERT OR IGNORE makes enqueueing safe to re-run: items already in the ledger keep their status.

Chunk by count and by bytes

A single batch is capped at 100,000 requests or 256 MB, whichever comes first; an oversized request body can come back as a 413 request_too_large. There is also a per-organization cap on batch requests waiting in the processing queue, which depends on your usage tier (200,000 at the Start tier, 500,000 at Scale), so a large backfill gets fed in waves anyway.

There is a second reason to keep batches smaller than the cap. Results become available only when every request in the batch has finished, or after 24 hours. Ten batches of 10,000 give you results as each finishes. One batch of 100,000 gives you nothing until the slowest request is done.

MAX_REQUESTS = 10_000          # hard cap is 100,000; smaller batches return sooner
MAX_BYTES = 200_000_000        # stay well under the 256 MB cap


def chunks(requests: list[dict]):
    batch, size = [], 0
    for req in requests:
        n = len(json.dumps(req).encode())
        if batch and (len(batch) >= MAX_REQUESTS or size + n > MAX_BYTES):
            yield batch
            batch, size = [], 0
        batch.append(req)
        size += n
    if batch:
        yield batch
Enter fullscreen mode Exit fullscreen mode

Validate one request synchronously first

Validation of each request's params happens asynchronously, and validation errors come back only when the whole batch has ended. A typo in a parameter name turns into 10,000 errored results an hour later. Send one request with exactly the batch shape to the ordinary Messages API before submitting. While you're at it, remember the three parameters a batch rejects: stream: true, speed (fast mode) and max_tokens: 0.

Submit with an intent marker

There is a crash window between "the batch was created" and "the ledger knows its ID". Mark the rows submitting before the call and submitted after it. If a restart finds rows stuck in submitting, check client.messages.batches.list() for recent batches before re-submitting. If you do end up submitting twice, the deterministic custom_id means the second result overwrites the first rather than creating a duplicate: it costs money, not correctness.

MAX_ATTEMPTS = 3


def submit(rows: list[tuple[str, str]], build_params) -> str:
    """rows: (custom_id, record_id). build_params(record_id) returns the Messages params."""
    ids = [(cid,) for cid, _ in rows]
    db.executemany("UPDATE ledger SET status='submitting' WHERE custom_id=?", ids)
    db.commit()
    batch = client.messages.batches.create(
        requests=[{"custom_id": cid, "params": build_params(rid)} for cid, rid in rows],
    )
    db.executemany(
        "UPDATE ledger SET status='submitted', batch_id=?, attempts=attempts+1 WHERE custom_id=?",
        [(batch.id, cid) for cid, _ in rows],
    )
    db.commit()
    return batch.id
Enter fullscreen mode Exit fullscreen mode

Poll gently, and don't expect a progress bar

processing_status moves from in_progress to ended (via canceling if you cancel). The request_counts object looks like a progress meter but isn't one: succeeded, errored, canceled and expired all stay at zero until the entire batch has ended, and only processing is meaningful before then. Poll with backoff: the Batches API has its own requests-per-minute limit, and a tight polling loop spends it.

import random
import time

from anthropic.types.messages import MessageBatch


def wait_for(batch_id: str, max_sleep: float = 300.0) -> MessageBatch:
    delay = 30.0
    while True:
        batch = client.messages.batches.retrieve(batch_id)
        if batch.processing_status == "ended":
            return batch
        time.sleep(delay + random.uniform(0, 5))
        delay = min(delay * 1.5, max_sleep)
Enter fullscreen mode Exit fullscreen mode

expires_at on the batch object tells you when the 24-hour clock runs out. Any request not processed by then comes back as expired. The docs warn that processing can slow down with demand and with your own volume, and that more requests may expire as a result. Treat expiry as a normal outcome your retry path handles, not as an incident.

Four result types, and "succeeded" isn't the same as usable

Each result has one of four types:

Result type Meaning Billed? What to do
succeeded A message was created Yes Check stop_reason before trusting it
errored No message: invalid request or server-side error No Retry server errors; fix invalid_request_error first
canceled You canceled before it was sent to the model No Re-submit if still needed
expired The 24-hour limit hit before it was sent to the model No Re-submit, ideally in a smaller batch

The column that trips people up is the first one. A succeeded result can still carry stop_reason: "max_tokens" (truncated; remember that thinking counts toward the budget), "refusal", or "pause_turn" (a server-tool loop that paused and needs continuing). None of those is a usable answer. Route them to review, not into your table of answers.

Stream the results instead of downloading them whole (the SDK's results() iterator reads the .jsonl line by line) and write each one to durable storage as it arrives:

RETRYABLE = {"api_error", "overloaded_error", "rate_limit_error", "timeout_error"}
USABLE_STOPS = {"end_turn", "stop_sequence"}


def collect(batch_id: str) -> dict:
    stats = {"succeeded": 0, "errored": 0, "canceled": 0, "expired": 0, "read": 0, "write": 0}
    for n, line in enumerate(client.messages.batches.results(batch_id), start=1):
        outcome = line.result
        stats[outcome.type] += 1
        payload = None
        if outcome.type == "succeeded":
            msg = outcome.message
            stats["read"] += msg.usage.cache_read_input_tokens or 0
            stats["write"] += msg.usage.cache_creation_input_tokens or 0
            if msg.stop_reason in USABLE_STOPS:
                status, error, payload = "succeeded", None, msg.model_dump_json()
            else:
                status, error = "failed", f"stop_reason:{msg.stop_reason}"
        elif outcome.type == "errored":
            error = outcome.error.error.type
            status = "retry" if error in RETRYABLE else "failed"
        else:                                   # canceled or expired: never reached the model
            status, error = "retry", outcome.type
        db.execute(
            "UPDATE ledger SET status=?, error=?, result_json=? WHERE custom_id=?",
            (status, error, payload, line.custom_id),
        )
        if n % 500 == 0:
            db.commit()
    db.commit()
    total = stats["read"] + stats["write"]
    stats["hit_rate"] = stats["read"] / total if total else None
    return stats
Enter fullscreen mode Exit fullscreen mode

stats["hit_rate"] is the measured value to feed back into should_batch().

Re-submit only what failed

With the ledger in place, the whole job is a loop: pick rows that are pending or retry and under the attempt cap, chunk them, submit, wait, collect, repeat until nothing is left. Re-using the same custom_ids in a new batch is fine, since uniqueness is only required within one batch, and it keeps every attempt for an item under a single key.

def run(build_params) -> None:
    while True:
        rows = db.execute(
            "SELECT custom_id, record_id FROM ledger "
            "WHERE status IN ('pending', 'retry') AND attempts < ?",
            (MAX_ATTEMPTS,),
        ).fetchall()
        if not rows:
            break
        batch_ids = [submit(chunk, build_params) for chunk in chunks(rows)]
        for batch_id in batch_ids:
            wait_for(batch_id)
            print(batch_id, collect(batch_id))
Enter fullscreen mode Exit fullscreen mode

chunks() sizes ledger rows here, which is fine for small items; for large ones, size by the serialized params. Rows that end as failed (validation errors, truncations, refusals) or hit the attempt cap go to a person. Everything else converges on its own.

The clocks: 24 hours, 29 days

  • 24 hours from creation is the processing limit. You can't modify a batch once it's submitted. To change it, cancel it (status goes to canceling, and requests already in flight may still complete) and submit a new one. A canceled batch still ends with partial results for whatever was processed.
  • 29 days from created_at (not ended_at) is how long results stay downloadable. After that the batch object is still visible, with archived_at set, but its results are gone. That's why collect() writes to your own storage instead of treating the API as the system of record. Once results are stored, you can delete the batch with client.messages.batches.delete(batch_id); an in-progress batch has to be canceled first.

One more note from the docs: because of high throughput and concurrent processing, a batch can go slightly over your workspace's configured spend limit, so don't treat that limit as an exact cap for batch jobs.

Where batch is the right tool

Offline evals. An eval suite is the textbook batch workload. Nobody is waiting on the answer, it reruns on every prompt or model change, and it shouldn't use up the rate limits your product needs. Score the results from the ledger after the batch ends. If the suite itself is the weak part, start with a proper offline eval harness before optimizing what it costs to run.

Classification and extraction backfills. A new field to fill on a million old records, a taxonomy applied to an archive. Short outputs over a big shared prefix: this is the case to measure before you move.

Re-processing after a prompt change. Because PROMPT_VERSION is part of the custom_id, version 4 of the prompt creates new IDs next to version 3's. Run the new version as a batch, then diff the two result sets by record_id to see exactly which answers changed before switching production over.

Very long generations. With the output-300k-2026-03-24 beta header, batch requests on Opus 5.5, Sonnet 5.5 and several earlier models can set max_tokens up to 300,000, against 128K on the synchronous API. It's batch-only, available on the Claude API and Claude Platform on AWS, and a single generation that long can take over an hour, so plan around the 24-hour window.

Server tools at scale. Web search, web fetch and code execution work inside batches, and with no open connection to keep alive, the batch loop runs more iterations per turn before returning pause_turn.

And where it isn't:

  • Anything a user is waiting for, or that needs streaming (stream: true is rejected).
  • Fast mode (speed is rejected; fast mode tunes synchronous latency).
  • Cache pre-warming (max_tokens: 0 is rejected inside a batch).
  • Zero-data-retention or HIPAA workloads. Batch processing stores inputs and outputs for up to 29 days and isn't eligible for either arrangement. Under HIPAA readiness the API rejects it with a 400. Under ZDR it isn't blocked, but using it takes that data outside your ZDR arrangement.
  • Work with a hard deadline shorter than a day. "Most batches finish within an hour" describes the typical case. It isn't a guarantee.

The checklist

  • [ ] Compute the break-even hit rate for your actual prefix, fresh-input and output sizes, including thinking tokens in output
  • [ ] Pilot a few thousand items and measure the hit rate from usage before moving the full job
  • [ ] Explicit breakpoint at the end of the shared prefix, 1-hour TTL, a primer batch first
  • [ ] Identical system, tools, effort, thinking configuration and output format on every request
  • [ ] Deterministic custom_id from record ID plus prompt version; a ledger keyed by it
  • [ ] Chunk by count and bytes; smaller batches if you want results sooner
  • [ ] One synchronous dry run of the request shape before submitting
  • [ ] Check stop_reason on every succeeded result
  • [ ] Retry server errors, expired and canceled; fix invalid_request_error; cap attempts
  • [ ] Persist results as you stream them; they stop being downloadable 29 days after creation

FAQ

Does the batch discount apply to cached tokens?
Yes. Caching multipliers stack with the Batch API discount, so cache reads and writes are half price too. What batch can't promise is the hit: cache hits inside a batch are best-effort.

Are partial results visible while a batch is running?
No. Results become available when every request has finished, or after 24 hours, and the per-type counts stay at zero until then. If you need earlier partial results, submit several smaller batches.

Do batch requests count against Messages API rate limits?
No. The Batches API has its own limits, on HTTP requests per minute and on requests waiting in the processing queue, and batch usage doesn't affect your Messages API limits.

Want to go deeper?

A 50% discount looks like an easy call until you put it next to the caching bill you already have. Then it's a calculation, and sometimes the answer is to stay synchronous. Working out cost per request and per customer before choosing an architecture is a recurring thread in Build and Ship a Production AI SaaS, which follows an LLM product from unit economics to paying users, and it pairs well with the caching and batching material in the LLM integration track linked at the top.


Published by Cursuri-AI.ro, Eastern Europe's AI education platform, with hands-on courses on LLM integration, evaluation and AI engineering.

Prices, limits and behavior were checked against Anthropic's documentation on 2 October 2026: batch processing, pricing, prompt caching, rate limits and API and data retention. Prices and limits change, so re-check them before you commit a budget.

Top comments (0)