Use Batches for Bulk Tasks

Su Suriya Ravichandran Updated 10 Nov 2026
7 min read ·Lesson 139 of 200

Putting the Pieces Together

Lessons 131–138 each covered one stage of batch work: creating batches, assigning identifiers, watching status, listing batches, downloading results, interpreting outcomes, and retrying failures. A bulk task uses all of them at once. This lesson assembles those stages into one pipeline, runs it end to end on a realistic job, and explains the decisions that shape the design: how big each batch should be, how instructions are shared across requests, how results are checkpointed, and how the cost compares with synchronous calls.

The example job is classifying customer reviews. It is simple enough to follow, and it has the properties of most bulk work: many records, a shared set of instructions, a structured answer, and a need for every record to be accounted for.


The Pipeline Stages

Each stage reads from the previous one's output and writes something durable, which is what makes the pipeline restartable:

records ──► build requests ──► split into batches ──► submit ──► wait ──► download ──► process ──► store
  (source)   (stable IDs,        (size limits,        (save IDs)  (status)   (raw file)  (outcomes)  (database)
              shared prefix)      Lesson 132)           Lesson 133   Lesson 134  Lesson 136   Lesson 137

The batch IDs and the raw results file are the two checkpoints. If the process stops while batches are running, the saved IDs are enough to resume waiting. If it stops after download, the raw file is enough to reprocess without calling the API again.


Building Requests with a Shared Prefix

Every review classification uses the same instructions. Repeating those instructions in every request costs input tokens each time. Prompt caching (Lessons 121–130) can make the shared part cheap, and the documentation says it works in batches too, on a best-effort basis. For batches, the recommended approach is a one-hour cache entry on the shared block, because batches often take longer than the default five minutes.

"""bulk_pipeline.py: classify many reviews through batches, end to end."""

import json
from collections import Counter

from batch_builder import MODEL, validate, split_into_batches
from batch_status import wait_for_batch
from process_batch import cost_of, process_record

INSTRUCTIONS = (
    "You classify customer reviews of an online course as positive, negative, or mixed. "
    "Reply with JSON only, in the form {\"label\": ..., \"confidence\": ...}, where confidence "
    "is a number from 0 to 1. Judge the overall opinion, not individual words. "
) * 20     # Long enough to be worth caching

SYSTEM = [{"type": "text", "text": INSTRUCTIONS,
           "cache_control": {"type": "ephemeral", "ttl": "1h"}}]


def build_request(record: dict) -> dict:
    return {
        "custom_id": f"review-{record['id']:04d}",
        "params": {
            "model": MODEL,
            "max_tokens": 64,
            "system": SYSTEM,
            "messages": [{"role": "user", "content": f"<review>\n{record['text']}\n</review>"}],
        },
    }


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

Several choices here are deliberate. The custom_id comes from the record's stable numeric ID, zero-padded, so IDs sort correctly and never change when new records are added. The review text is wrapped in tags, which keeps it separate from the instructions (Lesson 37). The output limit is small, because a label and a confidence fit in a few dozen tokens, and a smaller limit reduces the risk of truncation.


Running the Pipeline

The pipeline separates the work from the service it talks to. The three service operations are passed in as functions: submitting a batch, fetching the results of a finished batch, and reporting progress. That separation lets the same pipeline run against the live API, or against a stand-in for testing:

def run_pipeline(records: list[dict], submit_batch, fetch_results, batch_size: int = 5):
    requests = [build_request(record) for record in records]
    problems = validate(requests)
    if problems:
        raise ValueError("; ".join(problems))

    batch_ids = []
    for chunk in chunked(requests, batch_size):
        batch_id = submit_batch(chunk)
        batch_ids.append(batch_id)
        print(f"submitted {batch_id}: {len(chunk)} requests")

    outcomes = []
    for batch_id in batch_ids:
        for line in fetch_results(batch_id):
            outcomes.append(process_record(line))

    status_counts = Counter(outcome.status for outcome in outcomes)
    labels = Counter(outcome.value["label"] for outcome in outcomes if outcome.status == "ok")
    tokens = sum(cost_of("claude-sonnet-5-5", o.input_tokens, o.output_tokens) for o in outcomes)
    return outcomes, status_counts, labels, tokens

The function validates every request before submitting anything, so a problem in the data is found before the first batch leaves. It submits in chunks, keeps the batch IDs, and processes the results only after all batches have been fetched. The totals at the end are the figures a team actually reports: how many records were classified, how many need attention, and what the job cost.


The Live Functions

With a real client, the three service operations use the calls from the earlier lessons. Submitting returns the ID. Fetching waits for the batch to end using the function from Lesson 134, then streams the results from Lesson 136:

def live_submit(client, chunk: list[dict]) -> str:
    return client.messages.batches.create(requests=chunk).id


def live_fetch(client, batch_id: str):
    wait_for_batch(client, batch_id)
    for result in client.messages.batches.results(batch_id):
        yield result.model_dump()

A production job would save each batch ID to the registry at the moment of submission (Lesson 133), so an interruption never loses track of a batch that is already running.


A Dry Run Without the API

To test the pipeline without spending money, the stand-in below produces results in memory. It returns successful classifications with realistic token counts, and it returns one invalid request, so the pipeline's handling of a failure is exercised too:

"""bulk_pipeline_demo.py: run the full pipeline against an in-memory stand-in for the API."""

import json

from bulk_pipeline import run_pipeline

LABELS = ["positive", "negative", "mixed"]
RECORDS = [{"id": n, "text": f"Review number {n}."} for n in range(1, 13)]
stored: dict[str, list[dict]] = {}


def dry_submit(chunk: list[dict]) -> str:
    batch_id = f"msgbatch_{len(stored) + 1}"
    lines = []
    for request in chunk:
        custom_id = request["custom_id"]
        if custom_id == "review-0007":
            outcome = {"type": "errored", "error": {"type": "error", "error": {
                "type": "invalid_request_error", "message": "unsupported input"}}}
        else:
            label = LABELS[int(custom_id.split("-")[1]) % 3]
            outcome = {"type": "succeeded", "message": {
                "model": "claude-sonnet-5-5", "stop_reason": "end_turn",
                "content": [{"type": "text", "text": json.dumps({"label": label, "confidence": 0.9})}],
                "usage": {"input_tokens": 120, "output_tokens": 4}}}
        lines.append({"custom_id": custom_id, "result": outcome})
    stored[batch_id] = lines
    return batch_id


def dry_fetch(batch_id: str):
    yield from stored[batch_id]


if __name__ == "__main__":
    outcomes, status_counts, labels, tokens = run_pipeline(RECORDS, dry_submit, dry_fetch, batch_size=5)
    print("status:", dict(status_counts))
    print("labels:", dict(labels))
    print(f"batch cost: ${tokens:.6f}")
    synchronous = sum(
        (o.input_tokens * 2.00 + o.output_tokens * 10.00) / 1_000_000 for o in outcomes
    )
    print(f"same work, synchronous prices: ${synchronous:.6f}")

Output:

submitted msgbatch_1: 5 requests
submitted msgbatch_2: 5 requests
submitted msgbatch_3: 2 requests
status: {'ok': 11, 'fix_request': 1}
labels: {'negative': 3, 'mixed': 4, 'positive': 4}
batch cost: $0.001540
same work, synchronous prices: $0.003080

How It Works

  1. The records become requests with stable IDs and a shared, cached system prompt. Twelve records produce twelve requests, which the validator accepts.
  2. The chunker splits the requests into batches of five, giving batches of 5, 5, and 2. In a real job the size would be set by the limits in Lesson 132, and usually be far larger.
  3. Each batch is submitted and its ID kept, so the run can be resumed after an interruption.
  4. The results are processed only after every batch has been fetched, using the same process_record() from Lesson 137, which turns each line into a uniform outcome.
  5. Record 7 comes back as fix_request, because its invalid-request error is classified exactly as in the previous lesson. It is not counted in the cost, because it has no usage.
  6. The cost is half the synchronous cost. Eleven records at 120 input and 4 output tokens cost $0.001540 at batch prices, against $0.003080 at standard prices. The difference is the 50% batch discount, applied to the same work.

Choosing the Batch Size

The demonstration uses five requests per batch only to make the splitting visible. A real job should use batches as large as the limits and the operational needs allow. Larger batches reduce the number of submissions and status checks. Smaller batches make each unit of work easier to recover, and they let early results arrive sooner, which matters when the job is monitored by people.

The practical guidance is to keep batches large enough that overhead is negligible, and small enough that one failed batch does not hold up the whole job. Thousands of requests per batch is a common range for short tasks like classification, with the byte limit checked on the actual data.


Monitoring a Long Job

For a job that runs for hours, progress reporting matters more than the final numbers. The status function from Lesson 134 can report after every check, and the listing from Lesson 135 can summarize every batch in the job at once. A simple dashboard needs only those two calls: one to show which batches are still running, and one to show the running totals of succeeded and errored requests.


When the Pipeline Is Not Worth Building

A pipeline like this has real overhead: a registry, retries, validation, and monitoring. For a few hundred records that need an answer in the next minute, a synchronous loop is simpler and fast enough. The pipeline pays for itself when the volume is large, the answers can wait, and the work must be reproducible, auditable, and resumable.

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 Use Batches for Bulk Tasks and get answers drawn from it.

Signed-in readers only.