Every week I see developers burning unnecessary budget on Claude API calls β running classification jobs sequentially, hammering the real-time endpoint with batch-sized workloads, and wondering why their AI infrastructure bill keeps climbing. The fix is the Message Batches API: send up to 10,000 prompts in a single asynchronous request and pay 50% less per token, per Anthropic's documentation. That's not a minor optimization β on a real bulk workload it can cut your monthly API spend in half.
This tutorial walks through the complete implementation in Python and C# (.NET). By the end, you'll know how to submit a batch, poll for completion, download results as JSONL, and decide when batches make sense versus real-time calls. These are the same patterns used in backend data-enrichment pipelines that trigger overnight Claude jobs and pull results the next morning.
If you haven't read my guides on Claude API prompt caching or structured outputs, both stack cleanly with batching β you can combine prompt caching and structured response schemas inside individual batch requests for compounding cost reductions. I'll point out the integration hooks as we go.
What Is the Claude Message Batches API?
The Message Batches API is an asynchronous endpoint for high-volume, non-time-sensitive workloads. Instead of sending one /v1/messages call at a time and waiting for each response, you bundle up to 10,000 requests into a single batch payload, submit it, and poll for results β all at a 50% token discount on both input and output tokens, per Anthropic's pricing page.
The tradeoff is latency. Batch requests don't return immediately; Anthropic processes them asynchronously and the turnaround depends on batch size and current load. Per the documentation, batches expire 29 days after creation, but the expires_at field on a batch object gives you the exact cutoff. In practice, small batches of a few hundred requests typically complete within minutes; larger jobs take longer. For anything user-facing β a chatbot response, a real-time autocomplete suggestion, anything a person is watching β stick with the standard endpoint. For scheduled pipelines, the batch API is the right call.
Common workloads where this shines: classifying support tickets by category and urgency, extracting structured fields from hundreds of documents, running nightly code quality checks across a repository, generating SEO metadata for a product catalog, or re-running embeddings when you upgrade models. The pattern is always the same: one type of prompt, applied to many inputs, where you can wait for results. If that describes your workload, read on.
Prerequisites: Setting Up Your Environment
You'll need an Anthropic API key from the console at console.anthropic.com. For Python, install the official SDK: pip install anthropic. The SDK handles authentication, serialization, and polling helpers. It also exposes a client.messages.batches namespace with dedicated methods for every batch operation. For .NET, you'll call the REST API directly via HttpClient β I'll show you exactly how in a dedicated section below.
For this tutorial I'm using Claude Haiku (claude-haiku-4-5) β the fastest and cheapest Claude model, and the right choice for classification, extraction, and labeling tasks where you don't need deep reasoning. If your use case involves complex code review or multi-step analysis, bump to claude-sonnet-4-5. The batch API works identically with any Claude model; just swap the model string.
One practical setup note: if you're running these scripts on a local server or homelab machine, keep your API key in an environment variable β never hardcoded. For batch jobs specifically, result files can run into megabytes of JSONL depending on batch size and response length, so make sure your disk has headroom. If you want a compact, quiet machine for running scheduled Python jobs, the Beelink SER5 Mini PC (~$299) handles this kind of workload well β it's the same platform I run Proxmox and Ollama on.
Submitting Your First Batch Request
Each request in a batch gets a custom_id β a string you define, up to 64 characters β so you can map results back to your original inputs without maintaining a separate lookup table. The params field takes exactly the same arguments as a standard /v1/messages call: model, max_tokens, and messages. Here's a complete example that classifies support tickets:
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from env
tickets = [
{"id": "ticket-001", "text": "App crashes on iOS 18 when I tap the login button"},
{"id": "ticket-002", "text": "How do I export my data as a CSV?"},
{"id": "ticket-003", "text": "Billing charged twice this month, need a refund"},
# ... up to 10,000 items
]
requests = []
for ticket in tickets:
requests.append(
anthropic.types.message_create_params.MessageCreateParamsNonStreaming(
custom_id=ticket["id"],
params={
"model": "claude-haiku-4-5",
"max_tokens": 64,
"messages": [
{
"role": "user",
"content": (
"Classify this support ticket into exactly one category: "
"Bug, Feature Request, or Billing. "
"Reply with only the category name.\n\n"
f"Ticket: {ticket['text']}"
),
}
],
},
)
)
batch = client.messages.batches.create(requests=requests)
print(f"Batch ID: {batch.id}")
print(f"Status: {batch.processing_status}") # "in_progress"
# Save the ID β you'll need it to poll and retrieve results
with open("batch_id.txt", "w") as f:
f.write(batch.id)
The response comes back almost instantly with a batch.id and processing_status of "in_progress". The batch is now queued for async processing. Make sure you persist the batch.id β without it you can't retrieve results, and the SDK doesn't maintain local state between script runs. A simple text file works for one-off jobs; for production use I store it in a database table alongside the job metadata.
A few notes on batch construction: each custom_id must be unique within the batch. Use your database primary key, a file hash, or any stable identifier β the goal is to avoid ambiguity when correlating results. Per Anthropic's documentation, the batch payload also has limits on total size, so extremely long individual prompts may reduce the number of items you can fit in a single batch. When in doubt, test with a small batch first and scale up.
Polling for Completion and Retrieving Results
Batch processing is async, so you poll the batch until processing_status flips to "ended". The batch object also exposes a request_counts breakdown β processing, succeeded, errored, canceled β so you can monitor progress for large batches. Here's a complete polling and result-download script:
import anthropic
import time
client = anthropic.Anthropic()
with open("batch_id.txt") as f:
batch_id = f.read().strip()
# Poll until the batch is done
while True:
batch = client.messages.batches.retrieve(batch_id)
counts = batch.request_counts
print(
f"Status: {batch.processing_status} | "
f"Processing: {counts.processing} | "
f"Succeeded: {counts.succeeded} | "
f"Errored: {counts.errored}"
)
if batch.processing_status == "ended":
break
time.sleep(30) # poll every 30 seconds
# Stream results as JSONL
print("Downloading results...")
results = {}
for result in client.messages.batches.results(batch_id):
if result.result.type == "succeeded":
text = result.result.message.content[0].text.strip()
results[result.custom_id] = text
elif result.result.type == "errored":
results[result.custom_id] = f"ERROR: {result.result.error.type}"
for ticket_id, category in results.items():
print(f" {ticket_id}: {category}")
The client.messages.batches.results() method streams the JSONL file and yields one typed result object per request. Always check result.result.type: a value of "errored" means that specific request failed (typically content filtering or a malformed prompt), but the rest of the batch still completes. Production code should handle partial failures gracefully β log errors, flag affected records for manual review, and continue processing the successful results.
For large batches, stream results directly to a file or database instead of accumulating them in a Python dict. The JSONL stream can be substantial for long responses, and loading everything into memory at once will cause problems at scale. If you're pairing batches with my structured outputs guide, add a Pydantic model to parse each result's JSON text into typed objects as you stream β clean, type-safe result handling with no post-processing step.
Implementing Message Batches in .NET (C#)
If your backend is .NET β as mine is for most client work β you call the Message Batches API directly via HttpClient. There isn't yet an official Anthropic .NET SDK with native batches support, so the REST approach gives you the most control. The key thing to know: the batches API requires an anthropic-beta header on every request, per the documentation.
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")!;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", apiKey);
http.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01");
http.DefaultRequestHeaders.Add("anthropic-beta", "message-batches-2024-09-24");
var tickets = new[]
{
new { Id = "ticket-001", Text = "App crashes on iOS 18 when I tap the login button" },
new { Id = "ticket-002", Text = "How do I export my data as a CSV?" },
};
var requests = tickets.Select(t => new
{
custom_id = t.Id,
@params = new
{
model = "claude-haiku-4-5",
max_tokens = 64,
messages = new[]
{
new
{
role = "user",
content = $"Classify this ticket into Bug, Feature Request, or Billing. " +
$"Reply with only the category name.\n\nTicket: {t.Text}"
}
}
}
}).ToArray();
var payload = JsonSerializer.Serialize(new { requests });
var content = new StringContent(payload, Encoding.UTF8, "application/json");
var response = await http.PostAsync(
"https://api.anthropic.com/v1/messages/batches", content);
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body); // {"id":"msgbatch_...","processing_status":"in_progress",...}
The anthropic-beta: message-batches-2024-09-24 header is required on every batches request β create, retrieve, results, and cancel. Forgetting it returns a 400 error that's easy to misread as a payload problem. Set it in your HttpClient's DefaultRequestHeaders once and all subsequent calls to batches endpoints will carry it automatically.
For polling in a .NET service, Hangfire background jobs work well. Create a job that retrieves the batch status on a schedule, and when processing_status equals "ended", streams the results endpoint line-by-line with StreamReader. Deserialize each JSONL line into a typed class with System.Text.Json. For result retrieval: GET https://api.anthropic.com/v1/messages/batches/{id}/results with the same headers. If you're building this into a broader .NET AI integration, the patterns from my Claude API tool use guide apply directly β you can compose tool-use calls inside batch requests the same way you would in real-time calls.
Real-World Developer Use Cases
The most common use case in practice is bulk data enrichment. You have a database table with thousands of rows β product descriptions, customer reviews, support tickets, blog posts β and you need to add a new field derived from the text: a category label, a sentiment score, a topic tag, a one-sentence summary. Running these sequentially at real-time API pricing is slow and expensive. Send them all as a batch overnight, collect results in the morning, and do one SQL bulk update. The operational pattern is clean and the 50% cost reduction makes it economically practical at scale.
Nightly code quality analysis is another pattern worth implementing. Set up a cron job (or a scheduled n8n workflow) that pulls every file modified in the last 24 hours from your Git repo, sends each to Claude with a review prompt, and writes the results to a dashboard or posts them as PR comments. At batch pricing with Haiku, you can cover a sizeable codebase without the API cost becoming a concern. Combine this with structured output schemas and you get machine-readable severity levels and specific line references that feed directly into your CI tooling.
Content localization and SEO metadata generation are two more where batching pays off quickly. If you're translating a product catalog or generating meta titles and descriptions for hundreds of pages, the batch API turns what would be a multi-hour sequential job into a fire-and-forget submission. For the broader engineering patterns behind building these kinds of AI processing pipelines at scale, AI Engineering by Chip Huyen (~$50) covers the system design decisions clearly β it's one of the most practically useful books on production LLM architecture available right now, per reviews from the ML engineering community. For a deeper look at the hands-on implementation side, Hands-On Large Language Models by Jay Alammar (~$60) pairs well with it.
Cost Analysis: When Batches Are Worth It
Per Anthropic's published pricing, Message Batches are billed at 50% of standard input and output token rates. Using Claude Haiku as an example: standard API input tokens run $0.80 per million, so batch input runs $0.40. Output tokens go from $2.00 to $1.00 per million. For Claude Sonnet models the discount is proportionally the same. If you're processing 1 million input tokens per month on batch workloads, that's a $400/month saving on Haiku β significantly more on Sonnet. At real scale, this matters.
The decision framework is simple: if your workflow is scheduled or triggered by a data pipeline (not by a user waiting for a response), it's a strong batch candidate. If a person is watching a spinner, use real-time. The break-even point in terms of latency tolerance is usually around one to five minutes β if you can design your workflow to accept that delay, you can almost always batch it. Nightly jobs, weekly reports, post-event enrichment pipelines, CI hooks that run after a PR merges β all of these are natural fits.
Don't underestimate the operational simplicity argument either. A batch workflow is one HTTP call to submit, a few polling calls to check status, and one download to collect the JSONL results. Compare that to managing concurrency, rate limits, exponential backoff, and retry logic for thousands of sequential real-time calls. The batching pattern is architecturally simpler and more resilient for bulk workloads. If a batch fails partway through, the completed requests still return their results β you only retry the errored ones.
Limitations and Gotchas
The most important constraint to internalize: batch requests do not support streaming. You get back the complete response for each item in the batch when the whole batch finishes β no character-by-character output, no incremental results as individual requests complete. This is by design given the async architecture, but it means batch is never the right tool for anything user-interactive. If you're building a UI feature, use real-time. Batching is a backend pattern.
Per Anthropic's documentation, individual batches are capped at 10,000 requests, and total payload size is also limited. For very large workloads, chunk your dataset into multiple batches and track them with a simple state table β columns for batch_id, status, submitted_at, item_count, and results_fetched. You can cancel an in-progress batch via POST /v1/messages/batches/{id}/cancel if you catch a bug in your prompt template before it finishes β this is much cheaper than paying for thousands of bad results.
One rate-limit detail that trips people up: even though batch processing is async, there are limits on how many batches you can have simultaneously in flight, based on your account tier. If you're submitting many batches in quick succession (for example, splitting a very large dataset into chunks), you may hit a limit on concurrent batch creation. Check your tier's limits in the Anthropic console before designing a workflow that submits dozens of batches simultaneously. For most use cases β one nightly job, one scheduled workflow β this isn't an issue, but it's worth knowing before you architect something at higher volume.
Want Claude dialed in for your workflow?
Book a 1-on-1 Claude setup call: 60 minutes, live, in English or Spanish, recorded for you. $127. Book the call β
Or start with the Claude Prompt Pack: 50 bilingual prompts, $17, instant download.