`validator=` overwrites, and cannot reject, a `gr.State` in the event's `inputs=`
Describe the bug
Expected: validator= only decides whether the call proceeds — it shouldn't touch the value of any input, gr.State included.
Actual: attaching validator= to an event listener overwrites any gr.State in that event's inputs= with the validator's own return value, a dict like {'__type__': 'validate', 'is_valid': ..., 'message': ...}. Unlike other components, which get a fresh value from the client on their next call, gr.State has no such correction.
A gr.State input also can't be rejected: an invalid verdict never blocks the call, though the same validator blocks it correctly for a Textbox.
Not submitting a PR — CONTRIBUTING.md currently pauses outside PRs, so this is a report only.
Have you searched existing issues?
- I have searched and found no existing issues
Reproduction
Both scripts launch the app in-process and drive it purely over HTTP via gradio_client — no browser needed.
1. The value is overwritten
Two triggers, same handler, same initial state value: one carries validator=, one (the control) doesn't. A third, validator-free trigger reads the same gr.State afterward, independent of the corrupting call.
import gradio as gr
from gradio_client import Client
def handler(state_val):
return repr(state_val)
def my_validator(state_val):
return gr.validate(True, "")
with gr.Blocks() as demo:
state = gr.State("genuine-value-42")
out = gr.Textbox()
with_validator_btn = gr.Button()
with_validator_btn.click(
fn=handler,
inputs=[state],
outputs=out,
validator=my_validator,
api_name="with_validator",
)
read_btn = gr.Button()
read_btn.click(fn=handler, inputs=[state], outputs=out, api_name="read_after")
control_state = gr.State("genuine-value-42")
control_out = gr.Textbox()
without_validator_btn = gr.Button()
without_validator_btn.click(
fn=handler,
inputs=[control_state],
outputs=control_out,
api_name="without_validator",
)
if __name__ == "__main__":
demo.launch(prevent_thread_lock=True)
client = Client(demo.local_url, verbose=False)
expected = repr("genuine-value-42")
with_validator = client.predict(api_name="/with_validator")
read_after = client.predict(api_name="/read_after")
without_validator = client.predict(api_name="/without_validator")
print("with validator=, handler received:", with_validator)
print("with validator=, state now holds:", read_after)
print("without validator= (control):", without_validator)
print(
"PASS" if with_validator == expected and read_after == expected else "FAIL",
"(with validator=)",
)
print("PASS" if without_validator == expected else "FAIL", "(control)")
demo.close()Output:
with validator=, handler received: {'__type__': 'validate', 'is_valid': True, 'message': ''}
with validator=, state now holds: {'__type__': 'validate', 'is_valid': True, 'message': ''}
without validator= (control): 'genuine-value-42'
FAIL (with validator=)
PASS (control)Also reproduces with the gr.State in either input position (when there's more than one input), and with the validator returning all-valid or all-invalid.
2. An invalid verdict on the same gr.State input doesn't block the call
Same validator, two targets: a gr.State and, for contrast, an ordinary Textbox.
import gradio as gr
from gradio_client import Client
def state_handler(state_val):
return repr(state_val)
def invalid_state_validator(state_val):
return gr.validate(False, "invalid")
def text_handler(text_val):
return repr(text_val)
def invalid_text_validator(text_val):
return gr.validate(False, "invalid")
with gr.Blocks() as demo:
state = gr.State("genuine-value-42")
state_btn = gr.Button()
state_out = gr.Textbox()
state_btn.click(
fn=state_handler,
inputs=[state],
outputs=state_out,
validator=invalid_state_validator,
)
tb = gr.Textbox(value="hello")
text_btn = gr.Button()
text_out = gr.Textbox()
text_btn.click(
fn=text_handler,
inputs=[tb],
outputs=text_out,
validator=invalid_text_validator,
)
if __name__ == "__main__":
demo.launch(prevent_thread_lock=True)
client = Client(demo.local_url, verbose=False)
try:
result = client.predict(api_name="/state_handler")
print("gr.State input: handler ran ->", result, "(should have been rejected)")
except Exception as e:
print("gr.State input: rejected ->", e)
try:
result = client.predict("hello", api_name="/text_handler")
print("Textbox input: handler ran ->", result, "(unexpected)")
except Exception as e:
print("Textbox input: rejected ->", e)
demo.close()Output:
gr.State input: handler ran -> {'__type__': 'validate', 'is_valid': False, 'message': 'invalid'} (should have been rejected)
Textbox input: rejected -> 1 parameter(s) failed validation:
- text_val: invalidExpected: both rejected. state_handler ran instead — the exact same validator, targeting a Textbox, correctly rejects the call.
Root cause (gradio==6.25.0) — identified by Claude, not independently verified
gradio/queueing.py: whenfn.validatoris set, Gradio buildsvalidator_fn = BlockFunction(inputs=fn.inputs, outputs=fn.inputs, postprocess=False, ...)— the validator's outputs are the real handler's inputs.gradio/route_utils.py: the validator call and the real call resolve the sameSessionState(samesession_hash).gradio/blocks.py,process_api:postprocess_dataruns unconditionally after everyBlockFunctioncall, including the validator's.gradio/blocks.py,postprocess_data'sblock.statefulbranch:Unlike the non-stateful branch just below it (if block.stateful: prediction_value = predictions[i] if utils.is_prop_update(prediction_value): if "value" in prediction_value: state[block._id] = prediction_value["value"] else: state[block._id] = prediction_value output.append(None)else:, which itself branches onblock_fn.postprocessvia nestedelifclauses), this branch ignoresblock_fn.postprocessentirely — sopostprocess=Falsehas no effect here.gradio/utils.py,is_prop_update: checks"update" in val.get("__type__", "").gr.validate()returns{"__type__": "validate", ...}, which fails that check, so theelsebranch writes the raw dict straight into thegr.State.
Since validator_fn.outputs == fn.inputs, and validators are documented to return one gr.validate() per input, any gr.State among those inputs receives that per-input result as its new value — every call, valid or not.
This same block.stateful branch is also why the invalid verdict never reaches the client for a gr.State slot: it does output.append(None) unconditionally instead of returning the validate dict. queueing.py's process_validation_response reads that None back from the response's data list, fails its isinstance(data, dict) check, and defaults to {"is_valid": True, "message": ""} for that slot — silently overriding whatever the validator actually returned. The sibling non-stateful branch has no equivalent short-circuit, which is why the same validator shape correctly rejects a Textbox input.
Both reproductions above were re-run against a fresh gradio==6.27.0 install (current PyPI release) and produce identical output — this isn't fixed by upgrading.
Narrower side effect: non-stateful inputs are also corrupted (dormant, exposed via DeepLinkButton)
The corrupting write isn't exclusive to gr.State — postprocess_data's non-stateful path writes the same raw validate dict into SessionState's tracked config value (state._update_value_in_config) for every input of the validated event, on every call, regardless of that input's own verdict. Normally this is invisible: the real handler runs right after and re-writes each input's correct value from the fresh request payload (preprocess_data), clobbering the validator's write before anything reads it back. But if any input is marked invalid, the real handler never runs at all — so none of the event's inputs get that self-healing write, and all of them stick with the corrupted dict, including inputs the validator itself marked valid.
This is dormant for ordinary interactive use (a live predict call always resupplies fresh values from the client, ignoring server-tracked config), but it does leak out through gr.DeepLinkButton ("Share via Link", auto-enabled on Hugging Face Spaces): its backend, GET /gradio_api/deep_link, snapshots exactly this tracked config and saves it as the state a shared link restores. A shared link generated after a rejected call corrupts every non-stateful input of that event this way, not just the one that failed validation — anyone who opens the link sees the raw {'__type__': 'validate', ...} dict instead of the real value. Confirmed this is the only consumer of that particular session data in the codebase (searched all references to SessionState.config_values/.components). Lower severity than the gr.State case — flagging for completeness since a fix should account for it too, not just the self-perpetuating gr.State symptom.
Screenshot
No response
Logs
System Info
Gradio Environment Information:
------------------------------
Operating System: Linux
gradio version: 6.25.0
gradio_client version: 2.6.0
------------------------------------------------
gradio dependencies in your environment:
anyio: 4.10.0
audioop-lts is not installed.
brotli: 1.1.0
fastapi: 0.141.1
gradio-client: 2.6.0
groovy: 0.1.2
hf-gradio: 0.4.1
httpx: 0.28.1
huggingface-hub: 1.16.1
jinja2: 3.1.6
markupsafe: 3.0.2
numpy: 2.3.2
orjson: 3.11.3
packaging: 25.0
pandas: 2.3.2
pillow: 11.3.0
pydantic: 2.11.7
pydub: 0.25.1
python-multipart: 0.0.20
pytz: 2025.2
pyyaml: 6.0.2
safehttpx: 0.1.7
semantic-version: 2.10.0
starlette: 1.6.0
tomlkit: 0.13.3
typer: 0.27.1
typing-extensions: 4.15.0
uvicorn: 0.35.0
mcp is not installed.
pydantic: 2.11.7
authlib is not installed.
itsdangerous is not installed.
gradio_client dependencies in your environment:
fsspec: 2025.7.0
httpx: 0.28.1
huggingface-hub: 1.16.1
packaging: 25.0
typing-extensions: 4.15.0Severity
I can work around it
Workaround: return gr.skip() instead of gr.validate() for the gr.State slot. Validation on that slot doesn't work regardless (see above) — this avoids the corruption without changing that.
Source: gradio-app/gradio