OpenAI Batch API JSONL Input Format & Per-Request Validation Errors
Batch API rejections are almost always about the file shape. Each JSONL line must wrap a normal request in a specific envelope with custom_id, method, url, and body. Mix endpoints, duplicate custom_ids, or use the wrong file purpose and the whole batch fails or partial requests error individually. Here's the strict spec and the fixes.
By Ahmed R. · Last updated Aug 14, 2026 · OpenAI · Page #151
{"custom_id": "unique-string", "method": "POST", "url": "/v1/chat/completions", "body": {...}}. Upload with purpose="batch". All lines in a batch must target the same endpoint (chat/completions, embeddings, responses, or completions — no mixing). Max 50,000 lines per batch, 200MB per file. Duplicate custom_id values fail the batch. Individual lines can fail without killing the batch — results appear in the error_file_id.Real error messages you'll see
openai.BadRequestError: Error code: 400 - {'error': {'message': "Files uploaded for use with batch must have purpose 'batch'. Received 'assistants'.", 'type': 'invalid_request_error'}}
# Files must be uploaded with purpose="batch" before being referenced in a batch job.
openai.BadRequestError: Error code: 400 - {'error': {'message': "The requests in the file target a mix of endpoints. All requests in a batch must target the same endpoint.", 'type': 'invalid_request_error'}}
# Every url in the JSONL must be identical.
# batch.request_counts: {"total": 1000, "completed": 942, "failed": 58}
# 58 items failed validation individually. Read error_file_id (not output_file_id) for details:
# {"id": "batch_req_...", "custom_id": "line-042", "response": null,
# "error": {"code": "invalid_request", "message": "duplicate custom_id"}}
Batch API limits & supported endpoints (2026)
| Limit / feature | Value |
|---|---|
| Max requests per batch | 50,000 |
| Max input file size | 200 MB |
| Completion window | 24 hours (typical 1-6h) |
| Discount vs sync | 50% on input AND output tokens |
| Supported endpoints | /v1/chat/completions, /v1/embeddings, /v1/completions, /v1/responses |
| File purpose | purpose="batch" (not "assistants") |
| Rate limit pool | Separate from sync API (typically much higher) |
| Endpoints per batch | Exactly one — no mixing |
| Partial failure handling | Failed lines go to error_file_id; successful continue |
Root causes (ranked by frequency)
Based on OpenAI developer reports; percentages sum to 100%.
- 24%File uploaded with wrong purpose.
purpose="assistants"or"fine-tune"is rejected — Batch requirespurpose="batch". - 18%Duplicate
custom_id. Every line must have a unique custom_id. Copy-pasting requests without renumbering triggers this on the whole batch. - 14%Mixed endpoints in one file. Half the lines target
/v1/chat/completions, half/v1/responses. Split into separate batches. - 12%Missing
method,url, orbodyfield. Envelope structure is required — a bare Chat Completions request body isn't enough. - 10%Body contains unsupported params for the endpoint. Same validation rules as sync API apply per-line —
temperatureon o3, streaming params, invalid model names all fail individually. - 9%File exceeds size or count limit. 50,000 lines / 200MB per batch. Larger jobs need chunking into multiple batches.
- 8%Newlines inside JSON values. JSONL is line-delimited — an unescaped
\ninside a message content string breaks parsing. Serialize withjson.dumps, not string concat. - 5%Streaming param passed in body.
"stream": trueis rejected for Batch — every request must be non-streaming.
How to fix it
Build the JSONL correctly — envelope + json.dumps + purpose="batch"
The correct shape for every batch input file.
The JSONL wrapper is: one JSON object per line, each with custom_id (unique string), method (always POST), url (endpoint path), and body (the request body you'd normally send). Serialize each line with json.dumps — never manual string building — to avoid unescaped newlines and quotes.
import io
import json
import uuid
from openai import OpenAI
client = OpenAI()
# ✅ Build the JSONL — one request per line
def build_batch_file(prompts: list[dict], model: str = "gpt-5.4") -> bytes:
"""prompts: list of {"id": str, "text": str, ...}
Returns bytes ready to upload."""
lines = []
seen_ids = set()
for p in prompts:
cid = p["id"]
if cid in seen_ids:
raise ValueError(f"duplicate custom_id: {cid!r}")
seen_ids.add(cid)
if len(cid) > 64:
raise ValueError(f"custom_id too long ({len(cid)} > 64): {cid[:40]}...")
request = {
"custom_id": cid,
"method": "POST",
"url": "/v1/chat/completions", # SAME endpoint for every line
"body": {
"model": model,
"messages": [
{"role": "user", "content": p["text"]},
],
"max_completion_tokens": 2000,
# Never include: "stream": true (batch is inherently non-stream)
},
}
lines.append(json.dumps(request, ensure_ascii=False))
return "\n".join(lines).encode("utf-8")
# ✅ Upload with purpose="batch"
def submit_batch(prompts: list[dict], endpoint: str = "/v1/chat/completions") -> str:
"""Returns batch_id."""
payload = build_batch_file(prompts)
if len(payload) > 200 * 1024 * 1024:
raise ValueError(f"file too large: {len(payload):,} bytes (max 200MB)")
if len(prompts) > 50_000:
raise ValueError(f"too many requests: {len(prompts)} (max 50,000)")
# Upload as file object
upload = client.files.create(
file=("batch_input.jsonl", io.BytesIO(payload)),
purpose="batch", # ← required
)
# Create batch job
batch = client.batches.create(
input_file_id=upload.id,
endpoint=endpoint, # must match every line's url
completion_window="24h", # only "24h" is supported
metadata={"submitted_by": "prod_pipeline", "batch_purpose": "nightly_classification"},
)
return batch.id
# ✅ Responses API in Batch
def build_responses_batch(prompts: list[dict]) -> bytes:
lines = []
for p in prompts:
request = {
"custom_id": p["id"],
"method": "POST",
"url": "/v1/responses", # different endpoint
"body": {
"model": "gpt-5.4",
"input": p["text"],
"instructions": p.get("instructions", "You are a helpful assistant."),
"max_output_tokens": 2000,
},
}
lines.append(json.dumps(request))
return "\n".join(lines).encode("utf-8")
# ✅ Embeddings batch
def build_embeddings_batch(texts: list[str], model: str = "text-embedding-3-small") -> bytes:
lines = []
for i, text in enumerate(texts):
request = {
"custom_id": f"emb-{i:07d}",
"method": "POST",
"url": "/v1/embeddings",
"body": {
"model": model,
"input": text,
},
}
lines.append(json.dumps(request))
return "\n".join(lines).encode("utf-8")
# ✅ Auto-chunk large workloads into multiple batches
def submit_in_chunks(prompts: list[dict], chunk_size: int = 40_000) -> list[str]:
"""Split into ≤50K-line batches automatically."""
batch_ids = []
for i in range(0, len(prompts), chunk_size):
chunk = prompts[i:i + chunk_size]
batch_id = submit_batch(chunk)
batch_ids.append(batch_id)
return batch_ids
# ❌ ANTI-PATTERN — manual string building
# NEVER do this:
# line = f'{{"custom_id": "{cid}", "body": {{"messages": [{{"content": "{content}"}}]}}}}'
# ↑ Any quote, newline, or backslash in `content` breaks the JSON.
# Always use json.dumps().
# ✅ Deterministic custom_id generation from source data
def make_custom_id(row: dict) -> str:
"""Stable, unique custom_id derived from source row keys.
Reproducible: same row → same custom_id, useful for idempotent re-submission."""
return f"row-{row['user_id']}-{row['record_id']}"
custom_id is your reconciliation key. Make it deterministic from source data (e.g. row-{user_id}-{record_id}) so re-submitted batches produce the same IDs — makes retries and idempotency trivial. Random UUIDs work but complicate matching results back to source records.Validate before upload — catch every per-line error locally
Fixes the "48 out of 1000 failed" surprise after the batch runs.
Batch validates per-line at execution time; a bad line doesn't fail the whole batch but shows up in error_file_id hours later. Do the validation locally before upload: check duplicate IDs, endpoint uniformity, model names, unsupported params. Costs milliseconds locally vs. hours of wasted batch capacity.
import json
from typing import Any
# Known unsupported per-endpoint params
CHAT_UNSUPPORTED = {"stream", "stream_options"}
RESPONSES_UNSUPPORTED = {"stream"}
EMBED_UNSUPPORTED = {"stream"}
REASONING_MODELS = {"o3", "o3-mini", "o4-mini", "gpt-5.4", "gpt-5.5", "gpt-5.6"}
REASONING_ONLY = {"reasoning_effort"} # only on reasoning models
NON_REASONING_ONLY = {"temperature", "top_p", "presence_penalty", "frequency_penalty",
"logit_bias", "logprobs", "top_logprobs"}
class BatchValidationError(Exception):
pass
def validate_batch_file(jsonl_bytes: bytes) -> list[dict]:
"""Parse the JSONL, check every line, return list of validation issues.
Empty list = valid."""
issues = []
seen_ids = set()
seen_endpoints = set()
lines = jsonl_bytes.decode("utf-8").split("\n")
if len(lines) > 50_000:
issues.append({"scope": "file", "issue": f"too many requests: {len(lines)}"})
if len(jsonl_bytes) > 200 * 1024 * 1024:
issues.append({"scope": "file", "issue": f"file too large: {len(jsonl_bytes):,} bytes"})
for i, raw in enumerate(lines):
if not raw.strip():
continue
line_no = i + 1
try:
req = json.loads(raw)
except json.JSONDecodeError as e:
issues.append({"line": line_no, "issue": f"invalid JSON: {e}"})
continue
# Envelope shape
for field in ("custom_id", "method", "url", "body"):
if field not in req:
issues.append({"line": line_no, "issue": f"missing field: {field}"})
if req.get("method") != "POST":
issues.append({"line": line_no, "issue": f"method must be POST, got {req.get('method')!r}"})
# custom_id uniqueness
cid = req.get("custom_id")
if cid:
if cid in seen_ids:
issues.append({"line": line_no, "issue": f"duplicate custom_id: {cid!r}"})
seen_ids.add(cid)
if len(cid) > 64:
issues.append({"line": line_no, "issue": f"custom_id too long: {len(cid)}"})
# Endpoint uniformity
url = req.get("url")
if url:
seen_endpoints.add(url)
# Per-endpoint body validation
body = req.get("body", {})
if url == "/v1/chat/completions":
for p in CHAT_UNSUPPORTED:
if p in body:
issues.append({"line": line_no, "issue": f"unsupported in batch: {p!r}"})
model = body.get("model", "")
if any(model.startswith(m) for m in REASONING_MODELS):
for p in NON_REASONING_ONLY:
if p in body:
issues.append({
"line": line_no,
"issue": f"{p!r} not supported on reasoning model {model!r}",
})
if "max_tokens" in body:
issues.append({
"line": line_no,
"issue": f"use max_completion_tokens (not max_tokens) on {model!r}",
})
elif url == "/v1/responses":
for p in RESPONSES_UNSUPPORTED:
if p in body:
issues.append({"line": line_no, "issue": f"unsupported in batch: {p!r}"})
elif url == "/v1/embeddings":
if not body.get("input"):
issues.append({"line": line_no, "issue": "embeddings request missing input"})
elif url:
issues.append({"line": line_no, "issue": f"unsupported batch endpoint: {url}"})
# Endpoint uniformity (only one)
if len(seen_endpoints) > 1:
issues.append({
"scope": "file",
"issue": f"mixed endpoints not allowed in one batch: {seen_endpoints}",
})
return issues
# ✅ Fail-fast wrapper — refuse to upload if any issues
def submit_batch_safe(jsonl_bytes: bytes, client) -> str:
issues = validate_batch_file(jsonl_bytes)
if issues:
report = "\n".join(f" · {i.get('line', 'file')}: {i['issue']}" for i in issues[:20])
more = f"\n ... and {len(issues) - 20} more" if len(issues) > 20 else ""
raise BatchValidationError(f"Found {len(issues)} issues:\n{report}{more}")
import io
upload = client.files.create(
file=("batch_input.jsonl", io.BytesIO(jsonl_bytes)),
purpose="batch",
)
batch = client.batches.create(
input_file_id=upload.id,
endpoint="/v1/chat/completions",
completion_window="24h",
)
return batch.id
# ✅ Sample a few lines for smoke test before full batch
def smoke_test_sample(jsonl_bytes: bytes, sample_size: int = 3, client=None):
"""Run first N lines synchronously to catch shape errors early."""
for raw in jsonl_bytes.decode("utf-8").split("\n")[:sample_size]:
if not raw.strip():
continue
req = json.loads(raw)
try:
# Actually invoke the endpoint synchronously
if req["url"] == "/v1/chat/completions":
client.chat.completions.create(**req["body"])
elif req["url"] == "/v1/responses":
client.responses.create(**req["body"])
print(f" ✓ {req['custom_id']}")
except Exception as e:
print(f" ✗ {req['custom_id']}: {e}")
raise
smoke_test_sample pattern is worth its cost — running 3-5 lines synchronously catches parameter/model bugs in seconds instead of waiting hours for the batch to fail. Especially valuable when experimenting with new model routing or per-request tools.Handle files with embedded newlines and quotes safely
The most common serialization trap.
JSONL fails silently when message content contains unescaped newlines, quotes, or backslashes. Always serialize each request through json.dumps — never build lines with f-strings or string concatenation. For files that already contain embedded JSONL, validate the parse before considering them ready.
import json
# ❌ ANTI-PATTERN — manual line building
def bad_build(prompts):
lines = []
for p in prompts:
# If content has quotes or newlines, this produces INVALID JSON
line = (
'{"custom_id": "' + p["id"] + '", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "gpt-5.4", "messages": [{"role": "user", "content": "'
+ p["text"] +
'"}]}}'
)
lines.append(line)
return "\n".join(lines).encode()
# ✅ CORRECT — json.dumps handles all escaping
def good_build(prompts):
lines = []
for p in prompts:
req = {
"custom_id": p["id"],
"method": "POST",
"url": "/v1/chat/completions",
"body": {
"model": "gpt-5.4",
"messages": [{"role": "user", "content": p["text"]}],
"max_completion_tokens": 2000,
},
}
lines.append(json.dumps(req, ensure_ascii=False))
return "\n".join(lines).encode("utf-8")
# ✅ Round-trip verify — parse what you produced
def build_and_verify(prompts) -> bytes:
data = good_build(prompts)
# Verify every line parses
for i, raw in enumerate(data.decode("utf-8").split("\n")):
if not raw.strip():
continue
try:
parsed = json.loads(raw)
assert "custom_id" in parsed, f"line {i+1}: missing custom_id"
except json.JSONDecodeError as e:
raise ValueError(f"line {i+1} invalid JSON: {e}")
return data
# ✅ ensure_ascii=False keeps Unicode readable, still valid JSON
# Compare:
# json.dumps({"content": "café ☕"}) → '{"content": "caf\u00e9 \u2615"}'
# json.dumps({"content": "café ☕"}, ensure_ascii=False) → '{"content": "café ☕"}'
# Both are valid; the second is smaller and human-readable.
# ✅ Streaming write for very large batches — don't hold 200MB in memory
import io
def stream_write_batch(prompts_iterable, output_path: str):
"""Write JSONL incrementally to disk, then upload."""
with open(output_path, "w", encoding="utf-8") as f:
for p in prompts_iterable:
req = {
"custom_id": p["id"],
"method": "POST",
"url": "/v1/chat/completions",
"body": {
"model": "gpt-5.4",
"messages": [{"role": "user", "content": p["text"]}],
"max_completion_tokens": 2000,
},
}
f.write(json.dumps(req, ensure_ascii=False))
f.write("\n")
# Then upload from file
def upload_from_disk(path: str, client):
with open(path, "rb") as f:
return client.files.create(file=f, purpose="batch")
# ✅ Compress oversized batches — Batch accepts uncompressed only, but you can
# split into 40K-line files instead of hitting the 200MB limit
def split_large_prompts(prompts, target_lines: int = 40_000):
for i in range(0, len(prompts), target_lines):
yield prompts[i:i + target_lines]
ensure_ascii=True) escapes every non-ASCII character to \uXXXX, roughly doubling file size for non-English content. ensure_ascii=False keeps text readable and small — safe because UTF-8 JSON is the standard.Prevention checklist
- Upload with
purpose="batch", not"assistants"— the file gets rejected otherwise. - Every JSONL line needs the envelope:
{"custom_id": ..., "method": "POST", "url": ..., "body": {...}}. - All lines in one batch target the SAME endpoint. Split into separate batches for chat + responses + embeddings.
- Enforce unique
custom_idper batch. Use deterministic IDs derived from source data for idempotent retries. - Never include
stream: truein a batch request body — batch is inherently non-streaming. - Always serialize with
json.dumps. Manual string building breaks on quotes, newlines, and backslashes. - Validate the file locally before upload (duplicate IDs, endpoint uniformity, unsupported params) — saves hours of batch capacity.
Frequently asked questions
No. Each batch has a single endpoint parameter (/v1/chat/completions, /v1/responses, /v1/embeddings, or /v1/completions), and every JSONL line's url must match it. If you have mixed workloads, submit them as separate batches. There's no penalty for multiple concurrent batches.
64 characters. Longer IDs are rejected at batch creation. Deterministic ID schemes work well within that limit: <shard>-<user_id>-<record_id> patterns usually fit. If your source keys are long (like URLs or UUIDs plus prefixes), hash them: hashlib.sha256(long_id.encode()).hexdigest()[:32].
Yes — the tools array and tool_choice parameter work identically to sync requests. The model returns function calls in the response, but there's no way to "reply" with tool results mid-batch. Multi-turn tool loops need to be split: batch A produces tool calls, your code executes them, batch B submits the results as follow-up requests.
Yes — image inputs work in batch chat completions and responses requests. Include image_url content blocks as normal. Base64-encoded images inflate file size quickly — for large vision batches, host images and pass URLs instead. Same 200MB file limit applies.
No. Once submitted, the batch is immutable — you can only cancel it (see error #152). To change parameters, cancel and resubmit with a new input file. Design for immutability: give batches meaningful metadata tags so you can identify a stale batch to cancel when a corrected one is ready.