multiselect=True turns gr.Dropdown into a multi-pick input — and the value becomes a list.
There is no gr.Multiselect component in Gradio — the multi-pick input is one boolean away from a dropdown you already know.
gr.Dropdown has no separate multiselect class: set multiselect=True and the same component accepts several selections. Your function then receives a list of the selected values — an empty list when nothing is picked, not None. Choices stay the same: plain strings/numbers, or (label, value) tuples where the user sees the label and your function gets the value. Verified defaults in 6.18.0: an omitted multiselect value is [], an explicit value=None selects nothing, and a str value gets wrapped to [value]. The frontend caps selections at max_choices, and allow_custom_value=True lets users type entries that are not in the list.
Pick-many widgets are everywhere in ML demos: select checkpoint A/B candidates, stack filter tokens before inference, choose which metrics a report shows. CheckboxGroup covers short fixed lists, but only the multiselect dropdown gives you a compact, type-to-filter picker with an enforced max_choices limit and free-text custom entries. And because an empty selection is [] (falsy), 'apply no filters' is a plain branch in your function instead of a None check followed by defensive defaults.
import gradio as gr
MODELS = {
"clip-vit-l/14": {"params": "428M", "task": "image+text"},
"whisper-large-v3": {"params": "1.55B", "task": "audio"},
"flan-t5-xl": {"params": "3B", "task": "text"},
"sam-vit-h": {"params": "636M", "task": "segmentation"},
"segformer-b2": {"params": "27M", "task": "segmentation"},
}
with gr.Blocks() as demo:
gr.Markdown("## Model zoo — pick models to compare")
picks = gr.Dropdown(
choices=list(MODELS),
multiselect=True,
max_choices=5,
label="Models (up to 5)",
info="Pick two or more to compare side by side",
value=["clip-vit-l/14"],
)
table = gr.JSON(label="Comparison")
def compare(selected):
return {m: MODELS[m] for m in (selected or [])}
picks.change(compare, picks, table)
demo.launch(prevent_thread_lock=True)* Running on local URL: http://127.0.0.1:7880
* To create a public link, set `share=True` in `launch()`.
Renders: a labelled dropdown with the default chip clip-vit-l/14 shown, an info hint underneath, and a JSON panel fed by the .change listener. The function was called with exactly the payload Gradio passes after preprocess: compare(['clip-vit-l/14', 'sam-vit-h']) →
{'clip-vit-l/14': {'params': '428M', 'task': 'image+text'}, 'sam-vit-h': {'params': '636M', 'task': 'segmentation'}}
clearing both chips makes the input [] (verified default for an omitted multiselect value in 6.18.0) and compare returns {} — the no-filter branch.
Ran on gradio 6.18.0; the payloads shown are the real preprocess() output for those selections.
# (label, value) choices + type="index": the label is for humans, the index is what you get
import gradio as gr
CHECKPOINTS_V = [
("epoch-1 (fastest)", "epoch-1"),
("epoch-2", "epoch-2"),
("epoch-3", "epoch-3"),
("epoch-5", "epoch-5"),
("final (best)", "final"),
]
with gr.Blocks() as demo:
gr.Markdown("## A/B test: compare checkpoint evals")
a = gr.CheckboxGroup(choices=["epoch-1", "epoch-2", "epoch-3", "epoch-5", "final"],
label="Run A checkpoints", show_select_all=True)
b = gr.Dropdown(choices=CHECKPOINTS_V, multiselect=True, type="index",
label="Run B checkpoints (label ≠ value)")
out = gr.JSON(label="What your function receives")
def show(a_sel, b_idx):
return {"run_a": a_sel,
"run_b_indices": b_idx,
"run_b_values": [CHECKPOINTS_V[i][1] for i in (b_idx or [])]}
gr.Button("Compare").click(show, [a, b], out)
demo.launch(prevent_thread_lock=True)* Running on local URL: http://127.0.0.1:7881
* To create a public link, set `share=True` in `launch()`.
Renders: two side-by-side pickers — a CheckboxGroup with a select-all toggle on top, and a dropdown listing the display names ('epoch-1 (fastest)' … 'final (best)'). Proven by preprocess() with the payload ['epoch-1', 'final']: run A receives ['epoch-1', 'final'] (CheckboxGroup values), run B receives [0, 4] — indices into the choice list, which show() maps back to ['epoch-1', 'final'] via the (label, value) tuples.
CheckboxGroup is the other multi-select input — all choices visible as boxes (show_select_all included in 6.18.0) versus the dropdown's compact list. type='index' is the standard trick when positions beat strings.
import gradio as gr
PRESET_TAGS = ["portraits", "landscape", "macro", "night",
"black-and-white", "street", "architecture", "underwater"]
with gr.Blocks() as demo:
gr.Markdown("## Photo tagger — presets or type your own tags")
tags_in = gr.Dropdown(
choices=PRESET_TAGS,
multiselect=True,
allow_custom_value=True,
max_choices=6,
label="Tags (up to 6, free text allowed)",
)
out = gr.Textbox(label="Comma-joined tags your function receives")
tags_in.change(lambda t: ", ".join(t or []), tags_in, out)
demo.launch(prevent_thread_lock=True)* Running on local URL: http://127.0.0.1:7882 * To create a public link, set `share=True` in `launch()`. Renders: a tag picker that also accepts typed text not in the list. Verification: preprocess(['portrait', 'my-own-tag']) passes the free-text entry through untouched — ['portrait', 'my-own-tag'] — no filtering, no error. The frontend stops a 7th selection when max_choices=6.
max_choices is browser-enforcement only: postprocess/preprocess accept ['a','b'] with max_choices=2 (max_choices=2 recorded on the instance), and even a default value over the limit constructs silently. UX, not validation.
| Flag | Meaning |
|---|---|
multiselect=True | Multiple selections allowed; your function gets a list. Default value becomes [] instead of the first choice. |
choices=[...] | Strings/numbers, or ('display label', 'function value') tuples — the user sees the label, preprocess hands your function the value. |
max_choices=N | Frontend cap on selections (since 3.20.0). Browser-only: check len(selected) in your function if the limit matters. |
allow_custom_value=True | Users type values not in choices — free-text tags, model names, filter tokens. preprocess passes them through as-is. |
type='index' | Your function receives choice positions instead of values: [0, 4]. Pair with (label, value) choices to map back. |
value=[...] | Pre-selected list. Omitted → []; explicit value=None → nothing selected; a bare str is wrapped into a one-item list. |
filterable=True (default) | Typing filters the choice list. Forced True when allow_custom_value=True — you can't have free text without search. |
PR #2871 by dawoodkhan82 added multiselect to gr.Dropdown — no new class, no deprecation, one mode switch. The changelog snippet shows the shape immediately: gr.Dropdown(['angola', 'pakistan', 'canada'], multiselect=True, value=['angola']). CheckboxGroup was already there for always-visible boxes; the dropdown stayed the compact option.
3.20.0 (2023-03-04, PR #3211) added type-to-filter search and max_choices. 3.44.0 (2023-09-13, PR #5384) finally allowed (label, value) choice pairs and allow_custom_value together with multiselect — before that, custom values and multi-select were exclusive. In 6.x the component grew toolbar buttons (PR #12627, first shipped in 6.3.0): buttons=['select_all'] puts a select-all action in the dropdown header — verified on 6.18.0.
postprocess returns the list as-is, only warning on values outside choices. preprocess either passes values through (allow_custom_value=True) or raises gr.Error for unknown ones — Value: 'c' … is not in the list of choices: ['a', 'b'] — which Gradio surfaces as a friendly UI popup, not a stacktrace. type='index' maps selections to list positions in preprocess (verified: ['epoch-1', 'final'] → [0, 4]) and back in postprocess. The invalid-choice warning fires in two places: construction (defaults) and postprocess (returned values) — construction never raises. And max_choices exists only in the frontend Svelte code, which is exactly why the Python side never rejects an over-limit payload.