flex service tier where the model supports it, including openai/gpt-5.6-sol. Flex halves model token prices only; tool calls such as web_search bill at their normal price. When the batch ends you download an output file, one JSON line per request, and join it back to your rows by the custom_id you chose.
This recipe does that for a 12-row sample CSV. enrichment.py builds the request file and validates the results; run_batch.py handles upload, polling, and download. Two test files check the parser and batch lifecycle without an API key. The model returns JSON text for each company; your code validates the field types, joins it to the row, and decides what to retry.
What you need
- Python 3.10 or newer on macOS or Linux. The shell commands use Unix syntax.
- A Perplexity API key with API credits. Organizations with Zero Data Retention cannot use batch, because batch keeps inputs and outputs for 30 days; for them the endpoints return
403. httpx, a plain HTTP client. This recipe calls the four endpoints directly:POST /v1/files,POST /v1/batches,GET /v1/batches/{id}, andGET /v1/files/{id}/content. You do not need the Perplexity Python library.
model. This recipe uses openai/gpt-5.6-sol; see the Batch API guide for other supported models and request restrictions.
Set up
Everything lives in one directory. You create the first six files from this page; the script writes the rest.requirements.txt
requirements.txt
requirements.txt in it, then create a virtual environment, install the packages, and export your key.
Install and set the key
Install and set the key
The companies
Twelve well-known public companies with a name and a website. The website is in the prompt so the search lands on the right Apple. Save the file ascompanies.csv. Your real file can include additional columns.
The script replaces any existing enrichment or status columns and preserves your other columns. Schema validation checks the shape and types, not whether the claims are true or the news URL is trustworthy. Review returned sources before using the rows for customer-facing claims, and do not treat a web search as an exhaustive check for the latest news.
companies.csv
companies.csv
Build the request file
enrichment.py holds everything that does not touch the network. Each excerpt below is an exact slice of the file, which is at the end of the section.
Constants, schema, and instructions
MODEL is the one model every line uses. RESPONSE_SCHEMA is the JSON shape you want back, sent as response_format so the Agent API returns an object with exactly these five keys (Output Control). INSTRUCTIONS tells the model to confirm every field with web search and to return only the object. The schema is the contract; the instructions improve how often the model meets it.
Constants, schema, and instructions
Constants, schema, and instructions
Custom ids and the CSV
custom_id is how you get results back to rows, because the output file is not in input order. It must be unique within the file and 1 to 64 bytes. make_custom_id builds row-003-nvidia from the row number and a slug of the name, and truncates the slug on a UTF-8 boundary so a long or accented name cannot push it over 64 bytes. read_companies reads the CSV and attaches the id to every row.
make_custom_id() and read_companies()
make_custom_id() and read_companies()
One request per row
build_request_body is a normal Agent API request body: the model, the instructions, a prompt with the company name and website, the built-in web_search tool, and the response_format. The same function serves a batch line and a single synchronous POST /v1/agent call, which is how you spot-check a prompt change before you send 800 of them.
build_batch_lines wraps each body in the batch envelope: custom_id, method: POST, url: /v1/responses. write_jsonl writes one compact object per line with LF endings and no blank lines, which is what the upload validates.
build_request_body(), build_batch_lines(), and the JSONL helpers
build_request_body(), build_batch_lines(), and the JSONL helpers
Parse the results
What the recipe keeps per row
Result is one row’s outcome: a status, the five parsed fields, an error string, the Response id, and the API-reported cost with its included tool subtotal. ok is true only for completed; everything else goes to the retry file. message_text concatenates the output_text parts of every message item in a Response. parse_enrichment loads the JSON, strips a markdown fence if the model added one anyway, and returns None unless all five keys are present.
Result, message_text(), and parse_enrichment()
Result, message_text(), and parse_enrichment()
Output lines and error lines
An output line carries the full Response underresponse.body. parse_output_line reads response.body.status first: incomplete (for example max_output_tokens reached) and failed are not successes even though the line is in the output file. Only a completed response is parsed, and a response whose text is not the object you asked for becomes parse_error.
The error file has two line shapes, and parse_error_line handles both. A request the API rejected has error: null and a response with the HTTP status and error body. A request that stopped without an answer has response: null and an error with a code such as batch_expired, batch_cancelled, poll_timeout, or response_too_large.
parse_output_line() and parse_error_line()
parse_output_line() and parse_error_line()
Join, retry file, and summary
collect_results indexes every output and error line by custom_id, then walks your rows and marks any row with no line at all as missing. That happens when a batch ends failed and output_file_id is null, and it means no row can silently disappear from the join. write_enriched_csv writes every input row with its original columns, the five new ones, and status, error, and response_id. retry_lines returns the original request lines for every row that is not completed, so the retry file is ready to upload as the next batch. summarize counts rows by status and sums the per-request cost from the output file.
collect_results(), write_enriched_csv(), retry_lines(), and summarize()
collect_results(), write_enriched_csv(), retry_lines(), and summarize()
Complete enrichment.py
Save this asenrichment.py. It is the six excerpts above in order, nothing else.
Full code - enrichment.py
Full code - enrichment.py
Run the batch
run_batch.py is the HTTP side. It saves the file id and batch id to batch_state.json, and persists the create request’s job name and idempotency key before sending it. Resume with --resume and the same input file. If a create response was lost, resume within the API’s 24-hour idempotency window; after that, inspect your existing batches before creating another.
Errors and the upload
check turns an HTTP error into a BatchError with the step name and response body. A 404 means the batch or file was not found, belongs to another organization, or has expired.
upload_input posts the file as multipart/form-data with purpose=batch. The API validates every line before it stores anything. If a line is bad, the upload returns 400 with the line number and reason, and you fix the file rather than discovering the problem a day later.
Imports, constants, and check()
Imports, constants, and check()
Create the batch
create_batch posts the file id with endpoint: /v1/responses and a metadata.job label. The Idempotency-Key header makes the create safe to retry within 24 hours. The script saves both the key and metadata before sending the request, so a resume on the next day does not change the request if the original response was lost. Reusing a key with a different request returns 400.
upload_input() and create_batch()
upload_input() and create_batch()
Poll and download
poll_batch reads GET /v1/batches/{id} until the status is completed, expired, cancelled, or failed. It starts at 30 seconds between polls and backs off by half each time, up to five minutes, because a batch that takes hours does not need a request every 30 seconds and the batch endpoints have normal request rate limits. A 429 waits for Retry-After. A network error is logged and retried rather than ending the run, since the batch is still running on the server either way.
A final status means processing ended, not that every request succeeded. download_file fetches output_file_id and, when it is not null, error_file_id, writes each to disk so you keep the raw evidence, and parses the lines.
poll_batch() and download_file()
poll_batch() and download_file()
The run
main reads the CSV, writes batch_input.jsonl, and hashes it. With --requests it uploads an existing JSONL instead, such as batch_retry.jsonl, and keeps only the rows in it. With --resume it loads batch_state.json, refuses to continue if the state belongs to a different input file, and skips the upload or create steps it already finished. Then it polls, downloads, joins, writes the retry file, and prints the summary: counts by status, the batch’s token usage, the summed per-request cost, how long the batch took, and one line per row that needs a retry.
The batch object’s usage covers the output file only, so it is not your full bill. In all 14 captured responses, response.body.usage.cost.total_cost equaled input_cost + cache_creation_cost + output_cost + tool_calls_cost. The script sums total_cost and displays tool_calls_cost as an included subtotal, not an extra charge. Adding that subtotal again would double-count tools in these responses.
load_state(), save_state(), and main()
load_state(), save_state(), and main()
Complete run_batch.py
Save this asrun_batch.py. It is the four excerpts above in order, nothing else.
Full code - run_batch.py
Full code - run_batch.py
Prove it without an API key
test_enrichment.py runs the pure functions against fixtures shaped like the Batch API guide’s examples. It checks that the request file has no blank lines, unique ids within 64 bytes, POST, /v1/responses, and one model throughout; that a completed line parses, a fenced one parses, prose and partial objects are rejected, and incomplete is not a success; both error-line shapes; that the join keeps every row and marks a row with no line as missing; and that the retry file holds exactly the original lines for the rows that did not complete. Save it as test_enrichment.py.
test_enrichment.py
test_enrichment.py
Run the tests
Run the tests
Test the batch lifecycle
Savetest_run_batch.py next to the other files. It runs the HTTP workflow against a local test double, including a lost create response, a next-day resume with the same idempotency key and metadata, downloads, and a partial failure that exits 1. Run both test files with python -m pytest -q.
test_run_batch.py
test_run_batch.py
Spot-check one request first
Before you send a whole file, send one row’s body to the synchronous endpoint and read what comes back. The body is identical to a batch line, so this checks the prompt, the schema, and the parser against a real response.One synchronous request
One synchronous request
service_tier: default, and a total cost of $0.06412, including $0.04267 for prompt-cache creation.
Run it
From the directory with the six files, with the virtual environment active and the key exported:Submit the batch and wait
Submit the batch and wait
python run_batch.py --resume. Batches target completion within 24 hours; this is not a deadline. The script exits 1 if any row needs retrying or the batch did not complete, while still saving the available results.
Observed on October 21, 2026 on Linux with Python 3.12, using files extracted from this page. All 12 responses completed, passed field validation, and joined to the correct CSV rows. All reported service_tier: "flex". The batch’s created_at and completed_at were 21 seconds apart; polling and download finished in about 32 seconds. The create response was validating, followed by observed in_progress and completed poll responses. No failed, incomplete, or expired responses appeared, and error_file_id was null.
companies_enriched.csv has every input row. A completed row has the five new columns filled. Any other row has them blank and the reason in error. Open batch_output.jsonl when you want the full Response for a row, including the search results the model used; response_id in the CSV is the key.
The API returned 12 JSONL records with custom_id, response.status_code: 200, a full Response under response.body, and error: null. The code joins by id even when output order differs. When the API returns no error-file id, the script writes an empty local batch_errors.jsonl so an older run’s errors cannot remain there.
Retry what failed
batch_retry.jsonl is a valid batch input file containing only the requests that did not complete. Send it as its own batch and write the results to a separate CSV so the first join stays intact:
Retry run
Retry run
batch_output.jsonl, batch_errors.jsonl, batch_retry.jsonl, and batch_state.json. Copy the first run’s files aside if you want to keep them. Keep retry_input.jsonl unchanged so you can resume with python run_batch.py --resume --requests retry_input.jsonl --out companies_retry.csv. Inspect failed rows before sending them again; configuration errors need a fix, while transient failures may only need a retry.
The live 12-row run needed no retry. To test selective retry, a separate local copy omitted Apple’s result and replaced Microsoft’s response text with invalid JSON. The parser kept 10 completed rows, marked one missing and one parse_error, and selected only the two original requests. That was a local failure fixture, not a service failure.
Submitting those two requests with the command above created batch_01M5GBBXT2245TAFJZ06224M5N. Both completed on flex and passed field validation. The batch’s timestamps were 14 seconds apart; polling and download finished in about 32 seconds. It used 17,189 input tokens and 868 output tokens, and reported $0.06094 in total request costs. Its retry file was empty.
What it costs and how long it takes
There is no separate batch price. Requests run on theflex tier where the model supports it. Flex halves model token prices only; tool calls such as web_search bill at their normal price. Starting 20 to 24 hours after creation, requests that start or resume run on the default tier so the batch can finish before its 48-hour deadline. Requests that never start are not billed.
The 12-row live run reported $0.38162, including $0.06000 in tool_calls_cost. The two-row retry reported $0.06094, including $0.01000 for tools. The combined $0.44256 includes $0.07000 in tool charges; the totals are unchanged because total_cost already included them. The batch-level usage contained token counts, not a cost total. These are API-reported costs for this run, not a billing-ledger reconciliation or a price guarantee; inspect your own results.
The live batch metadata put expires_at 48 hours after created_at, and both input and output files had 30-day expiry metadata. A duplicate-id upload returned 400 with error.type: "invalid_request"; cancelling the completed batch returned 409 with error.type: "conflict". The run did not exercise a live request error file, active cancellation, or actual deadline expiry. The error-file parsers and failed-result paths are covered by the offline tests, not those successful live runs.
batch_input.jsonl can hold up to 50,000 requests and 200 MB. Your organization also has a cap on unfinished batch requests at once, from 50,000 at tier 0 to 1,000,000 at tier 6; a create that would exceed it returns 429 with the count and limit. The polling calls themselves have normal request rate limits, which is why the script backs off.
Adapt it
- Your CSV. Any columns work. The prompt reads
nameandwebsite; changebuild_request_bodyif yours are named differently. Everything else keys oncustom_id. - Your fields. Edit
ENRICHMENT_FIELDSandRESPONSE_SCHEMAtogether, then the prompt. The tests that check the join and the parser useENRICHMENT_FIELDS, so they follow. - Another model. Pick a supported model from the models page, change
MODEL, and spot-check the fields you care about before submitting the file. - Bigger files. Nothing changes until 50,000 lines or 200 MB. Past that, split the CSV and run one batch per chunk with a different
--jobname. - Unattended runs. Run
python run_batch.pyundernohupor a scheduler, and on failure rerun with--resume. The state file is per directory. - Audit trail. Keep
batch_output.jsonl. Each line’sresponse.body.idcan be fetched later withGET /v1/agent/{id}, and the search results inoutputshow which pages the answer came from.
Troubleshooting
Every Batch API error prints asBatch API failed: <step>: HTTP <status> <error body>.
404means the batch or file was not found, belongs to another organization, or has expired. Check the id and credential, then see the Batch API guide.upload: HTTP 400names the first bad line and why: a blank line, a second model, astream: true, afunctiontool, or acustom_idover 64 bytes. Nothing is stored; fix the line and rerun.create batch: HTTP 400with a message aboutIdempotency-Keymeans the key was reused with a different request. Inspect the saved state and existing batch before starting a fresh upload. Changing--jobduring--resumeintentionally keeps the original saved key and metadata.create batch: HTTP 429is the unfinished-request cap. Wait for earlier batches to finish or send a smaller file.HTTP 402means your credits are exhausted. A running batch pauses its unstarted requests until you add credits; the deadline does not move.HTTP 403means your organization has Zero Data Retention, and batch is not available to it.- A batch that ends
expiredstill has an output file. The rows that did not start are in the error file asbatch_expiredand land in the retry file automatically. parse_errorrows are completed responses whose text is not the object the schema describes. Open the row’s line inbatch_output.jsonl; if the text is prose or a partial object, tightenINSTRUCTIONS.incompleterows stopped before the object was finished, for example atmax_output_tokens.- Input, output, and error files expire 30 days after creation. Download before then; after that,
GET /v1/files/{id}/contentreturns404.