Skip to content

finish_reason Values in the OpenAI API and What Each One Means

9 min read · updated August 11, 2026

finish_reason is the field that tells you whether the answer you are holding is complete. Most client code reads choices[0].message.content and never looks at it, which is exactly how a truncated answer gets stored in a database as though it were a whole one.

The complete list

The field sits on each element of choices, not on the response, so with n above 1 there is one per completion. On the Chat Completions object the documented values are:

  • stop — the model reached a natural stopping point, or one of your stop sequences was produced.
  • length — generation was cut off by a token limit, either your max_tokens / max_completion_tokens or the model’s context window.
  • tool_calls — the model stopped in order to call one or more tools, which are in message.tool_calls.
  • function_call — the deprecated predecessor of tool_calls, returned only when you used the old functions parameter.
  • content_filter — content was omitted because it was flagged by a content filter.
  • null — not a terminal value. It appears on every streamed chunk except the last, and on a non-streamed response only in the unusual case of a completion still in progress.

That is the whole set. The response objects shown below are the documented shape with illustrative values and shortened content — they are here to show you where the field sits relative to everything else, not as evidence of a particular model’s behaviour.

stop, and the two ways to reach it

stop covers two different events that your code may well want to distinguish and the field does not. Either the model emitted its own end-of-turn token, or it produced one of the strings you passed in stop.

"choices": [{
  "index": 0,
  "message": { "role": "assistant", "content": "Amsterdam." },
  "logprobs": null,
  "finish_reason": "stop"
}]

The consequential detail for stop sequences is that the matched text is not included in content. If you set "stop": ["\n\n"] and the model produces a blank line, the content ends before it and there is nothing in the response telling you which sequence fired or that one fired at all. If you need to know, the usual approach is to make the stop sequences distinguishable by what precedes them, or to drop stop sequences in favour of a structure you can parse. The number of stop sequences accepted in one request is itself capped, so this is not a mechanism that scales to many delimiters.

length

length means the output was cut mid-generation. The content is real text and will usually look plausible — it very often ends mid-sentence, but on a list or a code block it can end at what looks like a natural boundary, which is why this cannot be detected by inspecting the string.

"choices": [{
  "index": 0,
  "message": { "role": "assistant", "content": "1. Check the connection\n2. Verify the cred" },
  "finish_reason": "length"
}],
"usage": { "prompt_tokens": 84, "completion_tokens": 256, "total_tokens": 340 }

Two distinct causes produce it. The cap you set is the obvious one and the arithmetic gives it away: completion_tokens equals your max_tokens exactly. The other is running out of context — prompt plus completion cannot exceed the model’s context window, so a long prompt shrinks the space left for the answer regardless of what you asked for. Omitting max_tokens does not remove the ceiling, it just moves it to whatever the context leaves over.

On reasoning models the same value has a third cause that catches people out: reasoning tokens count against max_completion_tokens, so a budget consumed entirely by reasoning returns finish_reason: "length" with an empty content string. An empty answer is the correct signal to raise the budget, not to retry the prompt.

tool_calls and function_call

When the model decides to call a tool it stops generating prose and returns the call instead. message.content is typically null and the payload is in message.tool_calls:

"choices": [{
  "index": 0,
  "message": {
    "role": "assistant",
    "content": null,
    "tool_calls": [{
      "id": "call_9xQ2v...",
      "type": "function",
      "function": { "name": "get_order", "arguments": "{\"id\":\"10482\"}" }
    }]
  },
  "finish_reason": "tool_calls"
}]

Note that arguments is a JSON string, not an object, and that it is model-generated — under the default non-strict mode nothing guarantees it validates against your schema, which is what strict function calling exists to fix. When parallel tool calling is enabled the array can hold several entries and finish_reason is still the single value tool_calls.

function_call appears only if you are still using the deprecated functions parameter, in which case the payload is in message.function_call and there is at most one. New code should not encounter it; old code that branches on finish_reason === "function_call" will silently stop matching the day it is migrated to tools, so the migration is a place to grep for the string.

content_filter

content_filter is documented on the completion object and means content was omitted because of a content filter. In practice it is seen far more often on Azure OpenAI deployments, where the content filtering system is part of the service and attaches its own content_filter_results block naming the category and severity.

It is worth keeping separate from a model refusal, which is not a filter event at all: a refusal is the model choosing to decline, it comes back with finish_reason: "stop" and ordinary prose, and under structured outputs it appears in a dedicated message.refusal field rather than in content. The two are produced by different systems and want different handling — a filter hit is a request you should not retry unchanged, a refusal is a prompt problem.

Handling it in code

The rule that prevents the whole class of bug: never use content without having branched on finish_reason first. A switch with no default is better than a check for one value, because the default is where a value you have not seen before shows up.

  1. On stop, accept the content. This is the only value on which the answer is complete.
  2. On tool_calls, execute the calls, append the assistant message and one role: "tool" message per call, and send the conversation back. Do not treat the empty content as an answer.
  3. On length, decide deliberately between raising the budget, asking for a shorter answer, and continuing from where it stopped. Never store the partial text as final, and never re-send the same request unchanged — it will truncate at the same place.
  4. On content_filter, surface it. Retrying identically will be filtered identically.
  5. On anything else, including a value added after you wrote the code, fail loudly rather than falling through to the success path.

When streaming, the field is null on every chunk until the final one, so the same branch has to run after the stream closes. The terminal chunk is easy to miss because it usually carries an empty delta — no content, just the finish reason — so a handler that skips chunks with no text will skip the only chunk that tells it how the generation ended:

data: {"choices":[{"index":0,"delta":{"content":" pool"},"finish_reason":null}]}
data: {"choices":[{"index":0,"delta":{},"finish_reason":"length"}]}
data: [DONE]

Note also that a streamed response carries no usage object by default, so you cannot cross-check completion_tokens against your cap unless you asked for it with "stream_options": { "include_usage": true }, which appends one extra chunk carrying usage and an empty choices array. Handling that chunk — one with no choice at index 0 at all — is a separate small piece of defensive code from handling the empty delta.

A stream that ends without ever delivering a non-null finish_reason is a third case again: it was cut by the transport rather than by the model. A dropped connection, a proxy timeout or a cancelled request all look like a truncated answer to the user and none of them is a token-limit problem, so they want a retry rather than a larger budget. Distinguishing them is the reason to record the terminal reason explicitly rather than inferring completeness from the fact that the loop exited.