#13855·gradio

`validator=` overwrites, and cannot reject, a `gr.State` in the event's `inputs=`

Author: GrimLefourbeCreated Sep 12, 2026Updated Sep 14, 2026
Labelsbug

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.

python
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.

python
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: invalid

Expected: 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: when fn.validator is set, Gradio builds validator_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 same SessionState (same session_hash).
  • gradio/blocks.py, process_api: postprocess_data runs unconditionally after every BlockFunction call, including the validator's.
  • gradio/blocks.py, postprocess_data's block.stateful branch:
    python
    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)
    Unlike the non-stateful branch just below it (else:, which itself branches on block_fn.postprocess via nested elif clauses), this branch ignores block_fn.postprocess entirely — so postprocess=False has 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 the else branch writes the raw dict straight into the gr.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.Statepostprocess_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

bash

System Info

bash
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.0

Severity

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.