Mapping Batch Job Status Values Between Providers
10 min read · updated August 11, 2026
The reason you cannot write a lookup table from one provider’s batch status to another’s is that they are not describing the same thing. One enum answers “what is the job doing, and did it work?”. The other answers only the first half and leaves the second to the individual results.
The enums describe different things
Every batch API has at least two levels of state: the job — has the provider accepted, started, stopped — and the request — did this particular unit of work produce an answer. Providers draw the line between those two levels in different places, and that is the whole source of the confusion.
OpenAI’s Batch API folds outcome into the job status: a batch reaches completed, or failed, or expired, and you can branch on that value. Anthropic’s Message Batches API keeps the job status purely about lifecycle — the terminal value is ended, which asserts nothing about whether anything succeeded — and puts the outcome on each result. Google’s batch jobs use a third convention again, a JOB_STATE_-prefixed enum inherited from its general long-running job machinery.
The file-based lifecycle
A batch created from an uploaded file passes through states that correspond to real stages of processing, and knowing which stage a value names tells you what to do about it:
validating— the input file is being checked line by line, before any inference happens. A batch that never leaves this state has a malformed file, not a capacity problem.failed— validation rejected the input. Terminal. This is a submission bug: bad JSON, aurlthat disagrees with theendpoint, a duplicatecustom_id. No inference was billed, and re-submitting the same file will fail the same way.in_progress— requests are running.finalizing— all requests are done and the output file is being written. Not terminal, and importantly not yet readable: the output file id may not be populated.completed— terminal, results ready. Note that this means the job completed; individual requests inside it can still have failed, which is what the request counts and the error file are for.expired— the completion window elapsed with work unfinished. Terminal, and partial: whatever finished is in the output file and the rest is not. This is the state most collectors get wrong, because it is neither success nor failure.cancellingandcancelled— you asked it to stop. Cancellation is not instantaneous; the first is the in-flight state and the second is terminal, again with partial results.
Alongside the status there is a request-count object with total, completed and failed. The invariant worth asserting in your collector is that completed plus failed equals total on a terminal batch; if it does not, the batch expired or was cancelled and you have work that was never attempted.
The inline lifecycle
The inline shape has a shorter job enum, in a field named processing_status rather than status:
in_progress— running.canceling— a cancellation has been requested and in-flight work is winding down.ended— terminal. That is all it says.
The outcome lives on each result line, in a result object whose type is one of succeeded, errored, canceled or expired — and the batch object carries a matching count object with a field per outcome plus a processing count for work still in flight. So the question “did my batch work?” is not answered by the status field at all. It is answered by reading the counts, or by streaming the results and tallying them.
This is the single most important fact for a migration. Code that branches if status == "completed" and translates to if processing_status == "ended" is not a faithful translation: it converts “the job succeeded” into “the job stopped”, and a batch in which every single request errored passes that check.
Values with no counterpart
Four asymmetries are worth naming explicitly.
validatingandfinalizinghave no inline equivalent. Validation happens synchronously when you post the requests array, so a malformed request is a 400 at submission rather than a job state you discover by polling. There is nothing to map these to, and nothing you need to: the code that handled them becomes error handling on the create call.- Job-level
failedhas no inline equivalent either. For the same reason. If your monitoring alerts on batches enteringfailed, that alert has to move to the submission path. expiredchanges level. It is a job state on one side and a per-request result type on the other. A dashboard that counts expired batches has to become one that counts expired requests, and the two numbers are not comparable — one batch expiring is one event; the requests inside it are thousands.- Cancellation semantics differ in spelling and in scope. One spells the transient state
cancelling, the othercanceling. That is a genuine trap in a string comparison, and it is the kind of thing a normalisation layer exists to absorb.
A normalised status you can act on
Rather than mapping provider A’s enum onto provider B’s, map both onto a third one of your own, defined by what your pipeline does next. Four states are usually enough:
PENDING -> keep polling; nothing to read yet RUNNING -> keep polling; partial results may exist TERMINAL -> stop polling; read results and tally outcomes REJECTED -> stop polling; the submission was invalid, do not retry as-is
Then derive success from the counts, never from the state. The predicate that survives both providers is: the job is terminal, and the number of results I successfully joined back to my manifest equals the number I submitted. Anything less is a partial run, whatever the status field says, and the difference is your re-submission set.
Treat an unknown status value as RUNNING rather than raising. Providers add intermediate states; a poller that crashes on an unrecognised string turns a routine vendor release into an outage for your pipeline, and the cost of being wrong in the other direction is one extra poll. The general treatment of the endpoint itself is in the batch inference API, and the submission-side mechanics are in migrating long-running batch jobs.