Skip to content

Why Gemini Returns an Empty Response With a Safety Block Reason

9 min read · updated August 11, 2026

IndexError: list index out of range on response.candidates[0], or ValueError: Invalid operation: The `response.text` quick accessor requires the response to contain a valid `Part`. Both mean the same thing: the model produced nothing usable, and the reason is in a field you have not read yet.

The symptom

The response arrives with HTTP 200. There is no exception from the transport and no error object. What is missing is content — either the candidates array is empty or absent entirely, or there is a candidate whose content has no parts. SDK convenience accessors then throw, which is why the traceback points at .text rather than at the cause.

A 200 with no content is deliberate. The request was valid and was processed; the service declined to return output. That distinction is why this is not a 400.

Two different blocks, two different fields

This is the whole page. There are two independent filters, they fire at different times, and they report in different places.

The prompt was blocked

The input was filtered before generation started. There are no candidates at all, and the reason is on promptFeedback:

{
  "promptFeedback": {
    "blockReason": "SAFETY",
    "safetyRatings": [
      {"category": "HARM_CATEGORY_HARASSMENT",        "probability": "NEGLIGIBLE"},
      {"category": "HARM_CATEGORY_HATE_SPEECH",       "probability": "HIGH"},
      {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "probability": "NEGLIGIBLE"},
      {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "probability": "NEGLIGIBLE"}
    ]
  }
}

The documented blockReason values include BLOCK_REASON_UNSPECIFIED, SAFETY (blocked by the safety settings, with the offending category identifiable from safetyRatings), OTHER (blocked for an unlisted reason), BLOCKLIST (terminology blocklist), PROHIBITED_CONTENT and IMAGE_SAFETY. They are listed in Google’s generateContent reference.

The output was blocked

Generation started, produced something, and the result was filtered. A candidate exists — possibly with no parts — and the reason is its finishReason:

{
  "candidates": [{
    "finishReason": "SAFETY",
    "index": 0,
    "safetyRatings": [
      {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "probability": "MEDIUM", "blocked": true}
    ]
  }],
  "usageMetadata": {"promptTokenCount": 41, "totalTokenCount": 41}
}

Documented finishReason values that mean “blocked” rather than “finished”: SAFETY, RECITATION (the output was too close to memorised training material), BLOCKLIST, PROHIBITED_CONTENT, SPII (sensitive personally identifiable information), IMAGE_SAFETY, LANGUAGE (an unsupported language) and OTHER. Note also MAX_TOKENS, which is not a block at all but can leave you with content you did not expect — every finishReason value and what it obliges you to do is worth reading once.

RECITATION and SPII are not safety-setting driven and cannot be turned off with safetySettings. If you are seeing those, changing thresholds will not help.

What each category means

The adjustable categories, as documented in Google’s safety settings guide:

  • HARM_CATEGORY_HARASSMENT — negative or harmful comments targeting identity or protected attributes.
  • HARM_CATEGORY_HATE_SPEECH — content that is rude, disrespectful or profane.
  • HARM_CATEGORY_SEXUALLY_EXPLICIT — references to sexual acts or other lewd content.
  • HARM_CATEGORY_DANGEROUS_CONTENT — promotion or facilitation of harmful acts.
  • HARM_CATEGORY_CIVIC_INTEGRITY — election-related queries; adjustable on the models that support it.

Each rating carries a probability from NEGLIGIBLE, LOW, MEDIUM, HIGH — and this is the field people misread. It is the probability that the content belongs to that category, not the severity of the harm. A HIGH rating on harassment means the classifier is confident it is harassment, not that the harassment is severe.

Diagnosing it in three lines

Stop using the .text accessor in any code path that can hit a filter, and read the two fields directly:

fb = getattr(response, "prompt_feedback", None)
if fb and getattr(fb, "block_reason", None):
    raise PromptBlocked(fb.block_reason, fb.safety_ratings)

if not response.candidates:
    raise EmptyResponse("no candidates and no block reason")

cand = response.candidates[0]
if cand.finish_reason.name not in ("STOP", "MAX_TOKENS"):
    raise OutputBlocked(cand.finish_reason.name, cand.safety_ratings)

text = "".join(p.text for p in cand.content.parts if getattr(p, "text", None))

The last line matters too: a candidate can contain parts that are not text — a functionCall, an executableCode — and joining only the text parts is what the convenience accessor does not do safely.

One accounting detail while you are here: a blocked request is still billed for its prompt tokens. usageMetadata on a prompt-blocked response reports promptTokenCount with no candidatesTokenCount, because the input was processed and nothing was generated. A retry loop that hammers a request which will always be blocked is not free, and on a long prompt it is not cheap either.

The same block in a stream

Streaming makes output blocking considerably more awkward, because text can reach the user before the filter fires. With streamGenerateContent the chunks arrive as they are produced; if the safety filter trips partway through, the stream ends and the final chunk carries the blocking finishReason. You have already emitted whatever came before it.

There is no way to unsend those tokens, so the design decision is yours and it has to be made deliberately. Either buffer the stream and release it only after a clean finish — which throws away the entire latency benefit of streaming — or render progressively and be prepared to visibly retract, replacing the partial text with a message when the final chunk says the response was blocked. Silently leaving a half-sentence on screen is the option to avoid, because the user reads it as a crash.

A prompt block behaves differently again: it fires before generation, so the stream yields nothing at all and completes immediately. An iterator that finishes with zero chunks is not a bug in your transport — check promptFeedback on whatever the API handed you before concluding the connection failed.

Fixing it

  1. Read which field carried the reason. promptFeedback.blockReason means your input was rejected and nothing you do to maxOutputTokens or the system instruction will help. A candidate finishReason means the input was fine.
  2. Identify the category from the safetyRatings entry with blocked: true, or the highest probability if none is flagged. Guessing which filter fired wastes the most time here.
  3. Adjust the threshold for that one category, if it is one of the adjustable ones and your use case legitimately needs it. Do not set everything to the loosest value reflexively:
    "safetySettings": [
      {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_ONLY_HIGH"}
    ]
    The documented thresholds are BLOCK_LOW_AND_ABOVE, BLOCK_MEDIUM_AND_ABOVE, BLOCK_ONLY_HIGH, BLOCK_NONE and OFF. See the harm categories and thresholds in full before changing anything.
  4. If the reason is RECITATION, thresholds do nothing. Rephrase the prompt so it asks for synthesis rather than reproduction, or raise the temperature slightly so the output diverges from the memorised sequence.
  5. Make the empty case a first-class outcome in your UI. Whatever you configure, some fraction of requests will be blocked. “The model declined to answer that” is a better product than a stack trace or an empty bubble.
One more cause that is not a filter at all: a reasoning model that spent its entire output budget on thinking tokens returns a candidate with no visible text and finishReason: MAX_TOKENS. If you see that, raise maxOutputTokens or lower the thinking budget rather than touching safety settings.