# Claude Message Batches API: Cut Bulk Job Costs in Python

> Run large asynchronous Claude jobs at 50 percent of standard API prices with the Message Batches API: Python create, poll and results code, limits and fixes.

Source: https://www.epifive.com/blog/claude-message-batches-api-python-guide · Published 2026-10-26 · EpiFive

## Key takeaways

- The Message Batches API charges 50 percent of standard prices on all tokens in exchange for asynchronous processing of up to 24 hours.
- A batch holds up to 100,000 requests or 256 MB, and each request needs a unique custom_id of 1 to 64 letters, digits, hyphens or underscores.
- Results come back in any order, so match them to your requests with custom_id and handle the four result types succeeded, errored, canceled and expired.
- Errored, canceled and expired requests are not billed, and a failure in one request does not affect the others.
- Combine batches with the 1-hour prompt cache for shared context, remembering that cache hits in batches are best-effort.

The Claude Message Batches API lets you submit up to 100,000 Messages requests as one asynchronous job and pays 50 percent of the standard price for all usage. You create the batch, poll until its `processing_status` is `ended`, then stream the results and match them to your requests by `custom_id`. It suits work where nobody is waiting for an answer: evaluations, classification, summarisation and bulk content generation.

## Who this is for and what you will have

This guide is for developers who already call Claude with the Python SDK and now have a large queue of independent prompts, such as thousands of documents to classify or summarise. By the end you will have a working Python script that builds a batch from a list of inputs, waits for it, writes results to disk, separates failures for retry, and respects the documented limits. It also covers when a batch is the wrong tool.

## When to use batches

A batch trades latency for price. Choose it when all of these are true:

- Each request is independent of the others.
- You do not need an immediate response; hours are acceptable.
- You have enough volume that a 50 percent discount matters.

Typical fits listed in the documentation are large-scale evaluations, content moderation, data analysis over large datasets and bulk content generation. Anything interactive, such as a chat window, should keep using the normal Messages API.

## Prerequisites

- An Anthropic API key in the `ANTHROPIC_API_KEY` environment variable.
- The Python SDK: `pip install anthropic`.
- A request shape that already works on the normal Messages API. Validation of each request's `params` happens asynchronously, and errors are only reported when the whole batch has ended, so test one request synchronously first.

## Limits and pricing you need to know

| Item | Value from the documentation |
|---|---|
| Requests per batch | 100,000 |
| Size per batch | 256 MB; whichever limit is reached first applies |
| Processing time | Most batches finish within 1 hour; a batch expires if not complete within 24 hours |
| Result availability | 29 days after the batch is created |
| Scope | A Workspace in the Claude Console |
| `custom_id` | 1 to 64 characters, matching `^[a-zA-Z0-9_-]{1,64}$`, unique within the batch |
| `max_tokens` | At least 1 on each request |
| Discount | 50 percent of standard API prices on all usage |

Batch prices are half of standard prices. At the time of writing the pricing page lists these batch rates, among others:

| Model | Batch input per million tokens | Batch output per million tokens |
|---|---|---|
| Claude Opus 5.5 | $2 | $10 |
| Claude Sonnet 5.5 | $1 | $5 |
| Claude Haiku 5.5 (prompts up to 100,000 tokens) | $0.05 | $0.25 |

Prices change, so confirm the current figures on the pricing page before you budget a job. All active models support the Batches API.

Two cautions from the documentation: rate limits apply both to the HTTP requests you make to the Batches API and to the number of requests waiting to be processed, and because of concurrent processing a batch can go slightly over the spend limit set for your Workspace.

### What cannot go in a batch

Most Messages features work in a batch, including vision, tool use, system messages, multi-turn conversations and extended thinking. These parameters are rejected with a validation error:

- `stream: true`, because results come back as one file.
- `speed` (fast mode), which targets synchronous latency.
- `fallbacks` (server-side fallback).
- `max_tokens: 0`, which is used for cache pre-warming.

## Step 1: Build and submit a batch

Each request has a `custom_id` and a `params` object holding ordinary Messages parameters. The following script classifies a list of support tickets.

```python
import anthropic
from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
from anthropic.types.messages.batch_create_params import Request

client = anthropic.Anthropic()

tickets = {
    "ticket-1001": "My invoice shows the wrong VAT number.",
    "ticket-1002": "I cannot sign in after changing my phone.",
    "ticket-1003": "How do I export my data to CSV?",
}

requests = [
    Request(
        custom_id=ticket_id,
        params=MessageCreateParamsNonStreaming(
            model="claude-sonnet-5-5",
            max_tokens=100,
            system="Classify the ticket as billing, access or how-to. Reply with one word.",
            messages=[{"role": "user", "content": text}],
        ),
    )
    for ticket_id, text in tickets.items()
]

batch = client.messages.batches.create(requests=requests)
print(batch.id, batch.processing_status)
```

The first response has `processing_status` set to `in_progress`, a `request_counts` object with every request under `processing`, an `expires_at` 24 hours after creation, and `results_url` set to null. Save the batch `id`; you need it to poll and fetch results.

Use meaningful `custom_id` values such as the ticket number. The documentation states that result order is not guaranteed, so the ID is the only reliable way to match a result to its input.

## Step 2: Poll until the batch ends

The `processing_status` starts as `in_progress` and becomes `ended` once every request has finished and results are ready. You can also watch batches in the Claude Console under your Workspace's batches page.

```python
import time

while True:
    batch = client.messages.batches.retrieve(batch.id)
    if batch.processing_status == "ended":
        break
    print(f"{batch.id}: {batch.request_counts}")
    time.sleep(60)

print(batch.request_counts)
```

A 60-second sleep matches the documentation's own example. Because batches can run for hours, in production you would store the batch ID in a database and poll from a scheduled job rather than keep a process running.

To see earlier batches in the Workspace, use the list endpoint, `client.messages.batches.list()`. Batches belong to the Workspace the API key runs in, so a key from another Workspace will not see them.

## Step 3: Read the results

Once the batch has ended, each request has exactly one of four result types.

| Result type | Meaning | Billed? |
|---|---|---|
| `succeeded` | The request worked and includes the message | Yes |
| `errored` | Invalid request or internal server error; no message was created | No |
| `canceled` | You canceled the batch before this request was sent to the model | No |
| `expired` | The 24-hour limit passed before this request was sent to the model | No |

Stream the results rather than downloading everything at once; the documentation recommends this because result files can be large. Results are served as JSON Lines.

```python
import json

succeeded = {}
retry_later = []
fix_first = []

for entry in client.messages.batches.results(batch.id):
    outcome = entry.result
    if outcome.type == "succeeded":
        succeeded[entry.custom_id] = outcome.message.content[0].text
    elif outcome.type == "errored":
        if outcome.error.error.type == "invalid_request_error":
            fix_first.append(entry.custom_id)   # fix the request body before re-sending
        else:
            retry_later.append(entry.custom_id)  # server error, can be retried directly
    elif outcome.type == "expired":
        retry_later.append(entry.custom_id)

with open("results.json", "w") as f:
    json.dump(succeeded, f, indent=2)

print(len(succeeded), "succeeded;", len(retry_later), "to retry;", len(fix_first), "to fix")
```

The documentation distinguishes two error classes: an `invalid_request_error` means the request body must be corrected before it is sent again, while other server errors can be retried as they are. Note that one failed request does not affect the others in the batch.

To retry, build a new list containing only the failed `custom_id` values and submit it as a second batch. A batch cannot be edited after submission.

## Step 4: Cancel or delete a batch

To stop a running batch, call the cancel endpoint. The status changes to `canceling` immediately, then to `ended` after finalization, and the results may contain partial output for requests processed before cancellation. Cancellation might not take effect instantly.

```python
client.messages.batches.cancel(batch.id)
```

Batch processing stores both inputs and outputs for up to 29 days after creation. You can delete a batch at any time after processing with `DELETE /v1/messages/batches/{batch_id}`; to delete an in-progress batch, cancel it first.

## Combining batches with prompt caching

Batch pricing and prompt caching discounts can stack. If every request starts with the same large system prompt or reference document, mark it with `cache_control` in each request. Because batch requests run concurrently and in any order, cache hits are best-effort, and the documentation says users typically see hit rates between 30 and 98 percent depending on traffic.

To raise the hit rate:

1. Put identical `cache_control` blocks in every request.
2. Keep requests flowing steadily so entries do not expire after the default 5-minute lifetime.
3. Share as much cached content between requests as possible.

Because batches can take longer than five minutes, the documentation suggests the 1-hour cache duration for batches with shared context. See [Anthropic prompt caching: cut cost and latency](https://www.epifive.com/blog/anthropic-prompt-caching-cost-latency) for the TTL pricing multipliers and minimum prompt lengths.

## Troubleshooting

### 413 request_too_large when creating a batch

The total request size exceeded 256 MB. Split the work into several smaller batches. The documentation also recommends smaller batches for manageability even when under the limit.

### Every request ends as errored with invalid_request_error

Validation runs asynchronously, so you only learn about a malformed `params` object after the batch ends. Send one request through the regular Messages API first and compare its shape. Check that none of the unsupported parameters (`stream`, `speed`, `fallbacks`, `max_tokens: 0`) are present and that `max_tokens` is at least 1.

### Requests show as expired

The batch did not finish within 24 hours. Processing can slow when demand or your request volume is high. Resubmit only the expired `custom_id` values, possibly in smaller batches. Expired requests are not billed.

### Results are gone

Results are available for 29 days after the batch `created_at` time, not after `ended_at`. After that you can view the batch but not download results. Download and store results soon after a batch ends.

### Duplicate or invalid custom_id

Each `custom_id` must be unique in the batch and match `^[a-zA-Z0-9_-]{1,64}$`. Spaces, dots and slashes are not allowed, so replace them or use a hash of the original identifier.

### Cannot see a batch someone else created

Batches are isolated to a Workspace. Use an API key from the same Workspace, or a Console user with permission to view the Workspace's batches. Downloading results in the Console can also be disabled at the organization or Workspace level.

## Checklist

- Confirm the work is asynchronous and independent per request.
- Test one request on the Messages API before batching.
- Use unique, meaningful `custom_id` values, because order is not guaranteed.
- Keep each batch under 100,000 requests and 256 MB.
- Poll `processing_status` until it is `ended`, then stream results.
- Handle `succeeded`, `errored`, `canceled` and `expired` separately and resubmit failures in a new batch.
- Download results within 29 days of batch creation.
- Use identical `cache_control` blocks and the 1-hour cache if requests share a large prefix.
- Check current batch prices for your model before estimating a budget.

## References

- Batch processing (Claude API docs): https://platform.claude.com/docs/en/build-with-claude/batch-processing
- Message Batches API reference: https://platform.claude.com/docs/en/api/messages/batches/create
- Message Batches API rate limits: https://platform.claude.com/docs/en/api/rate-limits#message-batches-api
- Prompt caching: https://platform.claude.com/docs/en/build-with-claude/prompt-caching

## Frequently asked questions

### How much cheaper is the Claude Message Batches API?

All usage in a batch is charged at 50 percent of standard API prices, for input tokens, output tokens and special tokens alike. For example, the documentation lists Claude Sonnet 5.5 at 1 dollar per million input tokens and 5 dollars per million output tokens in batch mode.

### How long does a Claude batch take to finish?

Most batches finish in less than an hour, but a batch can take up to 24 hours. If processing is not complete within 24 hours the batch expires, and requests that were not yet sent to the model are marked expired and are not billed.

### What is the maximum size of a Message Batch?

A batch is limited to 100,000 Message requests or 256 MB, whichever is reached first. A batch above the size limit can return a 413 request_too_large error, so split very large datasets into several batches.

### How long can I download batch results?

Results are available for 29 days after the batch is created, not after it finishes. After that you can still view the batch, but its results can no longer be downloaded.

