Build a Batch Processor

Su Suriya Ravichandran Updated 10 Nov 2026
6 min read ·Lesson 198 of 200

The Goal

A batch processor takes a large set of records, sends them to the model as a batch job at half the standard price, and returns a labeled result for each record along with a clear account of what did not succeed. It is the production form of the bulk work from Section 8: a sentiment label for every review in a catalog, a category for every support ticket in a month, or a summary for every document in an archive.

The project gathers the batch lessons into one program. Requests are built with stable identifiers (Lesson 133), split into batches within the limits (Lesson 132), submitted, collected and processed (Lessons 136 and 137), and failures are classified so that only the right ones are retried (Lesson 138). The output is a report, not just a list of answers.


What You Will Build

A run_batch_job function that:

  1. builds one request per record, with a stable identifier from the record's own key,
  2. validates the requests before anything is submitted,
  3. splits the requests into batches and submits each one,
  4. collects the results and sorts them into succeeded, needs-fix, and retryable,
  5. reports the labels, the failures, and the cost at batch prices.

Project Structure

batch_processor/
├── processor.py    # building requests, validation, submission, and processing
└── demo.py         # an in-memory stand-in for the batch service, with one invalid record

Step 1: Building and Validating Requests

Each record becomes a request whose identifier comes from the record's own identifier, not from its position in the list. Validation checks the identifier pattern and rejects duplicates before a batch is submitted, since the batch service's own check arrives only when processing ends:

"""processor.py: a batch job from records to labels, with validation and cost accounting."""

import re
from collections import Counter

MODEL = "claude-sonnet-5-5"
CUSTOM_ID = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
BATCH_PRICE = {"input": 1.00, "output": 5.00}     # USD per million tokens, batch rates
BATCH_SIZE = 3


def make_request(record: dict) -> dict:
    return {
        "custom_id": f"rec-{record['id']}",
        "params": {
            "model": MODEL,
            "max_tokens": 16,
            "messages": [{"role": "user",
                          "content": f"Classify the sentiment of this review as positive, negative, "
                                     f"or mixed. Reply with one word.\n\n{record['text']}"}],
        },
    }


def validate(requests: list[dict]) -> list[str]:
    problems, seen = [], set()
    for request in requests:
        custom_id = request["custom_id"]
        if not CUSTOM_ID.match(custom_id):
            problems.append(f"invalid custom_id {custom_id}")
        if custom_id in seen:
            problems.append(f"duplicate custom_id {custom_id}")
        seen.add(custom_id)
    return problems


def chunked(items: list, size: int):
    for start in range(0, len(items), size):
        yield items[start:start + size]

Step 2: Submitting and Collecting

The job submits each chunk, keeps the batch identifiers, and then reads the results of every batch. The results arrive in no particular order, so each one is stored under its identifier:

def run_batch_job(client, records: list[dict]) -> dict:
    requests = [make_request(record) for record in records]
    problems = validate(requests)
    if problems:
        raise ValueError("; ".join(problems))

    batch_ids = [client.messages.batches.create(requests=chunk).id
                 for chunk in chunked(requests, BATCH_SIZE)]

    labels: dict[str, str] = {}
    needs_fix: list[str] = []
    retryable: list[str] = []
    cost = 0.0
    for batch_id in batch_ids:
        for line in client.messages.batches.results(batch_id):
            result = line.result
            if result.type == "succeeded":
                labels[line.custom_id] = "".join(
                    block.text for block in result.message.content if block.type == "text"
                ).strip().lower()
                usage = result.message.usage
                cost += (usage.input_tokens * BATCH_PRICE["input"]
                         + usage.output_tokens * BATCH_PRICE["output"]) / 1_000_000
            elif result.type == "errored" and result.error.error.type == "invalid_request_error":
                needs_fix.append(line.custom_id)
            else:
                retryable.append(line.custom_id)

    return {"batches": len(batch_ids), "labels": labels, "needs_fix": needs_fix,
            "retryable": retryable, "cost": cost}

How It Works

  1. Validation runs before submission, so a malformed identifier or a duplicate stops the job immediately, at no cost.
  2. The chunks are submitted in order, and the identifiers are kept so the job can be followed and resumed (Lesson 139).
  3. Each result is sorted by its type. A success adds a label and its cost. An invalid request goes to needs_fix, because resubmitting it unchanged would fail again. Any other failure goes to retryable, since a later attempt may succeed (Lesson 138).
  4. Costs are summed from the usage of successes only. Errored and canceled results carry no tokens, so they add nothing (Lesson 137).
  5. The report keeps the three outcomes separate, so a person can see what to correct and what to resubmit.

Step 3: A Demonstration

The demonstration uses an in-memory stand-in for the batch service. It processes six records in two batches, returns a label for each record except one, which has an invalid request, and reports usage on each success:

"""demo.py: a batch job over six reviews, with one invalid record."""

from collections import Counter
from types import SimpleNamespace

from processor import run_batch_job

LABELS = {1: "positive", 2: "negative", 3: "positive", 5: "mixed", 6: "positive"}


def success(text):
    return SimpleNamespace(type="succeeded", message=SimpleNamespace(
        content=[SimpleNamespace(type="text", text=text)],
        usage=SimpleNamespace(input_tokens=30, output_tokens=2)))


class DryBatches:
    def __init__(self):
        self.stored = {}
        self.count = 0

    def create(self, requests):
        self.count += 1
        batch_id = f"msgbatch_{self.count}"
        lines = []
        for request in requests:
            number = int(request["custom_id"].split("-")[1])
            if number == 4:
                result = SimpleNamespace(type="errored", error=SimpleNamespace(
                    error=SimpleNamespace(type="invalid_request_error")))
            else:
                result = success(LABELS[number])
            lines.append(SimpleNamespace(custom_id=request["custom_id"], result=result))
        self.stored[batch_id] = lines
        return SimpleNamespace(id=batch_id)

    def results(self, batch_id):
        yield from self.stored[batch_id]


if __name__ == "__main__":
    client = SimpleNamespace(messages=SimpleNamespace(batches=DryBatches()))
    records = [{"id": n, "text": f"review {n}"} for n in range(1, 7)]
    report = run_batch_job(client, records)
    print("batches submitted:", report["batches"])
    print("succeeded:", len(report["labels"]))
    print("labels:", dict(Counter(report["labels"].values())))
    print("needs fix:", report["needs_fix"])
    print(f"batch cost: ${report['cost']:.6f}")

Output:

batches submitted: 2
succeeded: 5
labels: {'positive': 3, 'negative': 1, 'mixed': 1}
needs fix: ['rec-4']
batch cost: $0.000200

How It Works

  1. The six records make two batches of three, which the report shows.
  2. Five records succeed, and their labels are counted. Positive appears three times, negative once, and mixed once.
  3. Record four is an invalid request, so it lands in needs fix, not in the retry list, because a second attempt would fail identically.
  4. The cost is five successes at forty micro-dollars each, which is 30 input tokens at one dollar per million, plus 2 output tokens at five dollars per million, for each record. The batch rate is half the standard price, which is why the figure is small.
  5. The report carries the counts a team needs, so the job is judged by what it produced, not only by whether it finished.

Going Live

The stand-in is replaced by Anthropic(), and the job runs the same way. In production, the batch identifiers are written to a registry as they are created (Lesson 133), and the retryable list is resubmitted as a new batch with an attempt count (Lesson 138), so that a failed job can be finished without repeating the successes.


Operating the Job

A large job runs for hours, and its status should be checked with the polling pattern from Lesson 134 while it runs. Its results should be saved as they arrive, so a crash does not lose completed work, and its cost should be reported against the batch budget (Lesson 186). The processor shown here reads results only after the batches finish, which is simple and correct for demonstration. A production version would process each batch as it completes.

0 Comments

Reviewed before they appear

No comments yet.

Anthropic SDK Using Python
Anthropic SDK Intro Claude API Intro SDK Requirements Install Anthropic SDK Upgrade the SDK Create an API Key Set API Keys Use Environment Variables Import Anthropic Create a Client Send Your First Message Choose a Claude Model Set Max Tokens Read a Response Print Response Text Understand Message Blocks Use System Prompts Send User Messages Set Temperature Handle Stop Reasons Messages API Intro Create Messages Use Message Roles Send Conversation History Continue a Conversation Build a Chat Loop Reset Chat History Use Multiple Turns Inspect Message IDs Read Usage Data Write Clear Prompts Set a System Prompt Add Context to Prompts Use Prompt Templates Add Examples to Prompts Request JSON Output Use XML Tags Control Response Length Improve Prompt Quality Avoid Prompt Injection Read Text Blocks Read Content Blocks Check Stop Reasons Count Input Tokens Count Output Tokens Count Tokens Before Sending Limit Output Tokens Use Stop Sequences Read Request IDs Parse API Responses Streaming Intro Stream Text Output Use Stream Events Use Stream Helpers Get Final Messages Stream Async Responses Handle Stream Errors Build a Live Chat Stream Long Responses Close Streams Safely Build a Chatbot Keep Chat History Manage Chat Sessions Add Chat Memory Build a CLI Chatbot Build a Web Chatbot Use Flask with Claude Use FastAPI with Claude Return JSON from an API Add Chat Error Messages Async SDK Intro Create an Async Client Send Async Messages Use asyncio Run Parallel Requests Stream Async Text Handle Async Errors Set Async Timeouts Close Async Clients Choose Sync or Async Tool Use Intro Define a Tool Create Tool Schemas Send Tools to Claude Read Tool Calls Run Python Tools Return Tool Results Continue After Tool Use Control Tool Choice Handle Tool Errors Use the beta_tool Decorator Create a Tool Runner Add Tool Docstrings Validate Tool Inputs Use Multiple Tools Limit Tool Calls Log Tool Activity Build a Weather Tool Build a Search Tool Build a Calculator Tool Structured Output Intro Request JSON Data Validate JSON Output Use Pydantic Models Handle Invalid JSON Extract Fields from Text Generate Typed Data Use Image URLs Send Base64 Images Read PDFs with Claude Use Output Schemas Build Data Extraction Test Structured Responses Send Image Inputs Upload Files Use File IDs Process Text Files Summarize Documents Extract Table Data Handle File Errors Read Cache Usage Reduce Repeated Input Costs Debug Cache Misses Prompt Caching Intro Enable Prompt Caching Cache System Prompts Cache Long Context Test Cache Performance Use Cache Breakpoints Cache Best Practices Message Batches Intro Create a Message Batch Set Batch IDs Check Batch Status List Message Batches Get Batch Results Process Batch Outputs Handle Failed Batch Items Use Batches for Bulk Tasks Compare Batches and Requests Handle API Errors Handle Rate Limits Retry Failed Requests Set Request Timeouts Handle Network Errors Use Exponential Backoff Build Reliable API Calls Use Extended Thinking Log API Errors Track Request IDs Paginate API Results Set Thinking Budgets Read Thinking Blocks Use Tool Search Use Web Search Tools Use Computer Tools Use Citations Use Token Counting Read Raw Responses Set Custom Headers Use Claude on AWS Bedrock Use Claude on Vertex AI Use Claude on Microsoft Foundry Configure Cloud Credentials Choose a Cloud Provider Compare API Integrations Configure Provider Clients Handle Cloud Errors Use Cloud SDK Extras Deploy Cloud Apps Protect API Keys Use a .env File Ignore Secrets in Git Validate User Input Reduce Prompt Injection Limit Tool Permissions Avoid Unsafe Tool Calls Sanitize Tool Results Set Usage Limits Review Security Logs Test SDK Functions Mock API Responses Test Error Handling Test Streaming Code Measure Token Usage Track API Costs Configure Logging Deploy with Docker Deploy a FastAPI App Monitor Production Apps Build a Claude Chatbot Build a Text Summarizer Build a Code Explainer Build a JSON Extractor Build a PDF Assistant Build a Streaming Chat App Build a Tool-Calling Agent Build a Batch Processor Build a Research Assistant Build a Claude API Dashboard
Ask about this post
AI Ask about this post

Ask questions about Build a Batch Processor and get answers drawn from it.

Signed-in readers only.