Handle Stop Reasons

Su Suriya Ravichandran Updated 10 Nov 2026
12 min read ·Lesson 20 of 200

What a Stop Reason Is

Every successful Messages API response has a stop_reason field that explains why Claude stopped generating:

message = client.messages.create(
    model="claude-opus-5-5",
    max_tokens=1024,
    messages=[{"role": "user", "content": "Hello!"}],
)

print(message.stop_reason)   # end_turn

A stop reason is not an error. The request succeeded (HTTP 200), and the response contains valid content. The stop reason tells you what that content represents: a finished answer, a cut-off answer, a request to run a tool, a paused operation, or a refusal.

Why it matters: if your code always treats message.content as a finished answer, it will show truncated text as complete, ignore tool requests, and present refusals as normal replies. Checking stop_reason is what turns "it usually works" into reliable code.


All Stop Reasons at a Glance

stop_reasonWhen it occursWhat your code should do
end_turnClaude finished its response naturallyUse the response
max_tokensThe response reached your max_tokens limitRaise the limit, continue the response, or mark it as truncated
stop_sequenceClaude produced one of your custom stop_sequencesRead stop_sequence to see which one matched
tool_useClaude wants your application to run a toolRun the tool and send the result back
pause_turnA server-side tool loop (such as web search) reached its iteration limitSend the response back unchanged to let Claude continue
refusalClaude declined to respondRead stop_details, inform the user, or retry on a fallback model
model_context_window_exceededThe response filled the model's context windowTreat the response as truncated

The rest of this lesson explains each one with code.


Stop Reasons vs. Errors

These two concepts are easy to confuse, but they are handled in different places:

Stop reasonsErrors
HTTP status200 (success)4xx or 5xx
Where you see itmessage.stop_reasonA raised exception, such as anthropic.RateLimitError
Response contentValid (possibly partial)No message; an error description
Handled withif / match on the responsetry / except
import anthropic

try:
    message = client.messages.create(
        model="claude-opus-5-5",
        max_tokens=1024,
        messages=[{"role": "user", "content": "Hello!"}],
    )
except anthropic.APIStatusError as error:
    print(f"Request failed with HTTP {error.status_code}")   # An error
else:
    print(f"Request succeeded; stop_reason={message.stop_reason}")   # A stop reason

end_turn: A Complete Answer

The most common case. Claude finished what it had to say.

if message.stop_reason == "end_turn":
    answer = "".join(block.text for block in message.content if block.type == "text")
    print(answer)

The Rare Empty end_turn

Occasionally, a response has stop_reason == "end_turn" but no content. According to the documentation, this typically happens when Claude decides the turn is already complete. The most common causes are:

  • adding text blocks immediately after tool results in a user message, which teaches Claude a pattern of ending its turn, and
  • sending Claude's already-completed response back without adding anything new.

Retrying the identical request does not help, because Claude already decided it was done. If a fix to the message structure is not enough, add a new user message, such as "Please continue", and send again.


max_tokens: The Answer Was Cut Off

The response reached your max_tokens limit (Lesson 13). The text is incomplete, or missing entirely if thinking used the whole budget.

You have three options.

Option 1: Mark It as Truncated

def text_with_notice(message) -> str:
    text = "".join(block.text for block in message.content if block.type == "text")
    if message.stop_reason == "max_tokens":
        return f"{text}\n\n[Response truncated due to max_tokens limit]"
    return text

Option 2: Retry with a Higher Limit

This is the simplest fix when the answer fits within the model's maximum. Lesson 13 showed a complete retry function.

Option 3: Continue from Where It Stopped

You can ask Claude to continue. Send the original prompt, the partial answer as an assistant turn, and a new user message asking it to continue:

def get_complete_response(client, prompt: str, max_attempts: int = 3) -> str:
    messages = [{"role": "user", "content": prompt}]
    full_text = ""

    for _ in range(max_attempts):
        response = client.messages.create(
            model="claude-opus-5-5",
            max_tokens=4096,
            messages=messages,
        )
        full_text += "".join(b.text for b in response.content if b.type == "text")

        if response.stop_reason != "max_tokens":
            break

        messages = [
            {"role": "user", "content": prompt},
            {"role": "assistant", "content": full_text},
            {"role": "user", "content": "Please continue from where you left off."},
        ]

    return full_text

How it works:

  1. Each response's text is appended to full_text.
  2. If the response stopped naturally, the loop ends.
  3. Otherwise, a new conversation is built: the original prompt, everything written so far (as an assistant message), and an instruction to continue. The request ends with a user message, as current models require (Lesson 18).
  4. max_attempts prevents an endless loop for tasks that are genuinely too large.

Note: The joined text may have an awkward seam where one part ends and the next begins. For long outputs, raising max_tokens with streaming (Lessons 51–60) usually gives a cleaner result than continuation.


stop_sequence: A Custom Stop Point Was Reached

When you pass stop_sequences, Claude stops as soon as it produces one of them. The matched sequence is reported in message.stop_sequence, and it is not included in the text:

message = client.messages.create(
    model="claude-opus-5-5",
    max_tokens=1024,
    stop_sequences=["END", "STOP"],
    messages=[{"role": "user", "content": "Write one sentence about Python, then write END."}],
)

if message.stop_reason == "stop_sequence":
    print(f"Stopped at: {message.stop_sequence}")   # Stopped at: END

This is treated as a normal, intentional ending. Lesson 48 covers stop sequences in detail.


tool_use: Claude Wants to Run a Tool

When you give Claude tools, it can pause its answer to request one. The response contains one or more tool_use blocks, and your code must run the tools and send back the results:

if message.stop_reason == "tool_use":
    for block in message.content:
        if block.type == "tool_use":
            print(f"Run {block.name} with {block.input} (id={block.id})")

This is not a final answer. It is the middle of a workflow. Lessons 81–90 cover the full loop, and the SDK's tool runner (Lesson 92) can handle it automatically.


pause_turn: A Server-Side Operation Paused

Some tools run on Anthropic's servers, such as web search and code execution. If the server-side loop reaches its iteration limit (10 iterations per request by default), the response ends with pause_turn. To let Claude finish, send the response back unchanged as an assistant message:

if message.stop_reason == "pause_turn":
    messages.append({"role": "assistant", "content": message.content})
    message = client.messages.create(
        model="claude-opus-5-5",
        max_tokens=4096,
        messages=messages,
        tools=tools,   # The same tools as before
    )

You will only see pause_turn when using server tools (Lesson 155).


refusal: Claude Declined

Current Claude models include safety classifiers that can decline a request. A refusal is a successful response (HTTP 200), not an error. That is why monitoring based only on error rates will not notice refusals.

On a refusal, the stop_details object explains what happened:

{
  "content": [],
  "stop_reason": "refusal",
  "stop_details": {
    "type": "refusal",
    "category": "cyber",
    "explanation": "This request was declined because it could enable cyber harm."
  }
}
FieldMeaning
categoryThe policy area that triggered the classifier
explanationA human-readable description. The text is not stable, so display it but never parse it.

Both fields can be null when the refusal does not map to a named category. That is a normal value, not an error. For every other stop reason, stop_details itself is null.

Handling a Refusal

if message.stop_reason == "refusal":
    details = message.stop_details
    category = getattr(details, "category", None) or "unspecified"
    print(f"Claude declined this request (category: {category}).")

getattr(details, "category", None) safely reads the category even if details or the field is missing. or "unspecified" replaces a None value with readable text.

Retrying on a Fallback Model

According to the documentation, a request refused by Claude Fable 5.1, Opus 5.5, Sonnet 5.5, or Haiku 5.5 can usually be served by retrying on another Claude model. There are two ways:

  • Server-side fallback (beta): the request lists fallback models, and the API retries automatically. Claude Haiku 5.5 does not support it.
  • Client-side fallback: your code sends the same request again with a different model.

A simple client-side version:

PRIMARY_MODEL = "claude-opus-5-5"
FALLBACK_MODEL = "claude-opus-4-8"   # Check the "Refusals and fallback" docs for current recommendations


def create_with_fallback(client, **request):
    response = client.messages.create(model=PRIMARY_MODEL, **request)
    if response.stop_reason == "refusal":
        response = client.messages.create(model=FALLBACK_MODEL, **request)
    return response

Even after a fallback, the response can still be a refusal, so check stop_reason again.

Note: The official documentation also describes fallback credit, which avoids paying prompt-cache costs twice when you retry, and per-category recommended fallback models. See the "Refusals and fallback" and "Fallback credit" pages before building fallback logic for production.

Refusals in Conversations

A refusal can affect the rest of a conversation. The streaming-refusals guide recommends resetting the conversation context after a refusal rather than continuing to build on it. Lesson 25 shows how.


model_context_window_exceeded: The Context Window Is Full

The response filled the model's context window before reaching max_tokens. The content is valid but truncated, so handle it like max_tokens.

if message.stop_reason in {"max_tokens", "model_context_window_exceeded"}:
    print("[Response truncated]")

Note: At the time of writing, the SDK types this value only in its beta namespace (client.beta.messages). The non-beta response still carries the value at runtime, so comparing strings works, but a strict type checker may warn that the value is not one of the expected literals.


Practical Example: A Complete Response Handler

This handler converts any response into a clear result with a status, text, and an optional notice. It is the kind of function you would place in a shared service module.

"""response_handler.py: turn any Messages API response into a clear result."""

from dataclasses import dataclass

from anthropic import Anthropic
from anthropic.types import Message


@dataclass
class Result:
    status: str          # "ok", "truncated", "refused", "tool_call", "paused", "empty"
    text: str
    notice: str = ""


def get_text(message: Message) -> str:
    return "".join(block.text for block in message.content if block.type == "text")


def handle_response(message: Message) -> Result:
    text = get_text(message).strip()

    match message.stop_reason:
        case "end_turn" | "stop_sequence":
            if not text:
                return Result("empty", "", "Claude returned no text. Try adding a follow-up message.")
            return Result("ok", text)

        case "max_tokens" | "model_context_window_exceeded":
            return Result("truncated", text, f"Response truncated ({message.stop_reason}).")

        case "refusal":
            category = getattr(message.stop_details, "category", None) or "unspecified"
            return Result("refused", "", f"Claude declined this request (category: {category}).")

        case "tool_use":
            tools = [block.name for block in message.content if block.type == "tool_use"]
            return Result("tool_call", text, f"Claude requested tools: {', '.join(tools)}")

        case "pause_turn":
            return Result("paused", text, "A server-side operation paused; send the response back to continue.")

        case _:
            return Result("unknown", text, f"Unhandled stop reason: {message.stop_reason}")


if __name__ == "__main__":
    client = Anthropic()
    prompts = [
        ("Name three Python data types.", 1024),
        ("Write a detailed history of the Python language.", 60),
    ]
    for prompt, limit in prompts:
        response = client.messages.create(
            model="claude-opus-5-5",
            max_tokens=limit,
            messages=[{"role": "user", "content": prompt}],
        )
        result = handle_response(response)
        print(f"[{result.status}] {result.text[:80] or '(no text)'}")
        if result.notice:
            print(f"    {result.notice}")

Example output:

[ok] Three common Python data types are int, str, and list.
[truncated] Python was created by Guido van Rossum and first released in 1991. It b
    Response truncated (max_tokens).

How It Works

  • Result is a small dataclass. Callers check one simple field, status, instead of knowing every stop reason. Each status maps to a clear user-facing behavior.
  • match with | groups stop reasons that are handled the same way: end_turn and stop_sequence are both normal endings, and the two truncation reasons are both incomplete.
  • The empty check inside the normal case catches the rare empty end_turn response.
  • Refusals return no text, even if a fragment exists, so a refusal is never shown as an answer.
  • case _ handles any future stop reason safely instead of crashing.

Common Mistakes

Never Checking stop_reason

What happens: truncated answers, tool requests, and refusals are all treated as normal answers.

Fix: check stop_reason for every response your application depends on, or route all responses through one handler like handle_response().

Treating Refusals as Errors (or Ignoring Them)

What happens: refusals do not raise exceptions, so error monitoring misses them, and users may see an empty reply.

Fix: handle refusal explicitly, show a clear message, and track refusals as their own metric.

Parsing stop_details.explanation

What happens: code that searches the explanation text breaks when the wording changes.

Fix: branch on category if needed, and only display explanation.

Retrying an Empty Response Unchanged

What happens: the retry returns the same empty result.

Fix: fix the message structure, or add a new user message before retrying.

Continuing with a Trailing Assistant Message

What happens: ending the request with the partial assistant text, without a following user message, returns a 400 error on current models, because prefill is not supported.

Fix: always end with a user message, such as "Please continue from where you left off."


Best Practices

  • Route every response through one handler that maps stop reasons to clear outcomes.
  • Never show truncated or refused output as a complete answer.
  • Use generous max_tokens to make truncation rare, and streaming for long outputs.
  • Monitor stop reasons in production: count how often max_tokens, refusal, and empty responses occur.
  • Include a default branch for stop reasons your code does not handle yet.
  • Read the official refusal and fallback guides before building automatic fallback logic.

Exercise

  1. Use handle_response() with three requests: a normal question, a long-answer question with max_tokens=50, and a request with stop_sequences=["###"] asking Claude to write a sentence followed by ###. Print each result's status.
  2. Extend Result with a stop_reason field, and fill it in for every case.
  3. Modify get_complete_response() to print how many requests were needed to finish the answer.
  4. Write a function summarize_stop_reasons(responses) that takes a list of Message objects and returns a dictionary counting each stop reason.
Show answers for steps 3 and 4

Step 3: count the attempts and print the total after the loop:

def get_complete_response(client, prompt: str, max_attempts: int = 3) -> str:
    messages = [{"role": "user", "content": prompt}]
    full_text = ""
    attempts = 0

    for attempts in range(1, max_attempts + 1):
        response = client.messages.create(
            model="claude-opus-5-5", max_tokens=4096, messages=messages
        )
        full_text += "".join(b.text for b in response.content if b.type == "text")
        if response.stop_reason != "max_tokens":
            break
        messages = [
            {"role": "user", "content": prompt},
            {"role": "assistant", "content": full_text},
            {"role": "user", "content": "Please continue from where you left off."},
        ]

    print(f"Requests needed: {attempts}")
    return full_text

Step 4:

from collections import Counter


def summarize_stop_reasons(responses) -> dict[str, int]:
    return dict(Counter(response.stop_reason for response in responses))

Counter counts how often each value appears. Calling dict() converts it into a plain dictionary, such as {"end_turn": 8, "max_tokens": 2}.

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 Handle Stop Reasons and get answers drawn from it.

Signed-in readers only.