gradio · difficulty ◆◆
gradio Dataframe — the one component you can actually type into
gr.Dataframe is a pandas DataFrame with a keyboard attached — but only if you pass interactive=True.
Every Gradio input sends you one value. This one sends you a whole table — blank cells and all, and you will not get the blanks you expected.
$ gr.Dataframe(interactive=True)What it does
gr.Dataframe renders a two-dimensional grid. Hand it a pandas DataFrame and it becomes a read-only results board; set interactive=True and the cells turn into editable fields, which makes it the only core Gradio component where the user types structured data rather than a single value. Your function receives the grid back as a pandas DataFrame (the default; numpy, polars and list-of-lists are one `type=` away), with headers as column names and dtypes taken from `datatype`. Rows and columns can be generated on the fly (`row_count`, `column_count`), which is what turns it into an empty expense form or a batch-labeling sheet.
Why it matters
Two things people build demos for all day are 'let me tweak the inputs in a table' and 'show me the model's output as a table'. gr.Dataframe covers both, and the editable path unlocks the workflows that make a demo feel like a tool: correct a label before re-scoring, fill a small form without seven Textboxes, edit a config grid and re-run. The catch is that editability is a single boolean and the event wiring around it is denser than anywhere else in Gradio — one Enter in one cell can call four different listeners, twice each. Learn that once and the component stops surprising you.
Example
$ import gradio as gr
import pandas as pd
def evaluate(backbone: str):
rows = {"resnet50": [0.7623, 0.9210], "vit_b16": [0.8109, 0.9502], "convnext_t": [0.8431, 0.9640]}
acc, f1 = rows.get(backbone, [0.5, 0.5])
return pd.DataFrame({"backbone": [backbone], "top1": [acc], "f1": [f1]})
with gr.Blocks(title="Eval board") as demo:
gr.Markdown("### Benchmark board")
pick = gr.Dropdown(["resnet50", "vit_b16", "convnext_t"], label="backbone", value="resnet50")
run = gr.Button("Run eval")
board = gr.Dataframe(label="results", interactive=False) # output-only board
run.click(evaluate, pick, board)
demo.launch()Running on local URL: http://127.0.0.1:7860
Rendered component tree: Markdown "### Benchmark board" -> Dropdown (label "backbone", value resnet50) -> Button "Run eval" -> Dataframe (label "results").
What the browser is told for that Dataframe (read live from /config):
{"label": "results", "interactive": false, "type": "pandas", "max_height": 500}
Clicking Run eval passes pick's value into evaluate() and postprocesses the returned DataFrame:
postprocess -> {'headers': ['backbone', 'top1', 'f1'],
'data': [['vit_b16', 0.8109, 0.9502]],
'metadata': None}
The grid shows one row with the backbone name and two float columns; no cell is editable, so no keyboard ever appears.Output-only is the default posture: leave `interactive` alone, or set interactive=False explicitly as above. Verified on gradio 6.18.0 / pandas 3.0.3 — the config line is the actual response the launch served.
$ import gradio as gr
import pandas as pd
def total_claim(df: pd.DataFrame) -> str:
amt = pd.to_numeric(df["amount"], errors="coerce")
return (f"{len(df)} rows, {int(df['vendor'].isna().sum())} vendor cells still blank, "
f"total = {amt.sum():.2f} over {int(amt.notna().sum())} filled amounts")
with gr.Blocks(title="Claim lines") as demo:
lines = gr.Dataframe(
headers=["date", "vendor", "amount", "gl_code"],
datatype=["date", "str", "number", "str"],
column_count=4, row_count=6,
interactive=True, label="new expense lines",
)
run = gr.Button("Total the claim")
out = gr.Textbox(label="totals")
run.click(total_claim, lines, out)
demo.launch()Running on local URL: http://127.0.0.1:7860
Rendered component tree: one Dataframe labelled "new expense lines" (4 typed columns, 6 empty rows, every cell editable — click or double-click a cell and an inline editor appears with the current value), a Button "Total the claim", a Textbox "totals".
What a freshly rendered blank form hands the function (real run):
empty form -> 6 rows, 6 vendor cells still blank, total = 0.00 over 0 filled amounts
two lines typed -> 6 rows, 4 vendor cells still blank, total = 338.00 over 2 filled amounts
Note the shape: the form always submits 6 rows. Untouched cells arrive inside the DataFrame as NaN, not as '' — which is exactly why isna() counts them and why `total` works without a single None-check.The 6x4 form was verified end to end; the two result strings are the literal return values. `datatype` drives both the cell renderer (a date picker for "date", a checkbox for "bool") and the dtype coercion in preprocess().
$ import gradio as gr
def inspect_cell(ev: gr.SelectData):
return (f"index = {ev.index}\n"
f"value = {ev.value!r}\n"
f"row_value = {ev.row_value!r}\n"
f"col_value = {ev.col_value!r} <- the whole COLUMN, not the column name\n"
f"selected = {ev.selected}")
with gr.Blocks() as demo:
grid = gr.Dataframe(value=[["A-1", 4], ["B-2", 9]], headers=["sku", "qty"],
datatype=["str", "number"], interactive=True, label="stock")
picked = gr.Textbox(label="last cell clicked", lines=5)
grid.select(inspect_cell, None, picked) # SelectData is injected, so inputs=None
demo.launch()Running on local URL: http://127.0.0.1:7860
Rendered component tree: a 2x2 editable Dataframe labelled "stock" (headers sku/qty, header row visible) feeding a Textbox "last cell clicked".
One click on the 'A-1' cell, logged inside the handler:
index = [0, 0]
value = 'A-1'
row_value = ['A-1', 4]
col_value = ['A-1', 'B-2']
selected = True
Clicking the 9 in the bottom row instead gives index=[1, 1], value=9, row_value=['B-2', 9], col_value=[4, 9]. `index` is [row, col]; `value` is the cell; `row_value` is that cell's row; `col_value` is the entire column as a flat list.`col_value` is the trap here: the shipped docstring in 6.18.0 describes it as 'the value of the entire row', a copy-paste of the line above it. The runtime behaviour is the column.
$ import gradio as gr
def on_edit(df): return f"edit {df.shape}"
def on_change(df): return f"change {df.shape}"
def on_input(df): return f"input {df.shape}"
with gr.Blocks() as demo:
grid = gr.Dataframe(value=[["A-1", 4], ["B-2", 9]], headers=["sku", "qty"],
datatype=["str", "number"], interactive=True, label="stock")
out = gr.Textbox(label="last fired")
grid.edit(on_edit, grid, out)
grid.change(on_change, grid, out)
grid.input(on_input, grid, out)
demo.launch()Running on local URL: http://127.0.0.1:7860
Do this in the browser: double-click a cell (an inline 'Edit cell' textarea opens), type a new value, press Enter once. Handler invocations counted server-side for that ONE keystroke, repeated on two different cells:
trial 1 A-1 -> 'Z-9' edit=2 change=2 input=2 (0 of each before Enter)
trial 2 9 -> '42' edit=4 change=4 input=4 (that is +2 of each again)
Ordered timestamps for a single Enter:
edit shape=(2, 2)
change shape=(2, 2)
input shape=(2, 2)
edit shape=(2, 2)
change shape=(2, 2)
input shape=(2, 2)
So one committed edit is worth two rounds, always in the order edit -> change -> input.Measured with Playwright against the real launch, counters written from inside the Python handlers. If you wire all three listeners to an expensive function, you are paying for it twice per keystroke — pick one listener and make the other two cheap.
Common flags
- interactive=True
- The only switch that makes cells editable. Omit it (None) or set False and the grid is a picture: clicking and typing do nothing. Verified in a browser across all three settings.
- headers / column_count
- Column names and width. Passing both with mismatched lengths raises ValueError('The length of the headers list must be equal to the column_count...') at construction time.
- datatype
- [str, number, bool, date] per column. Sets the cell editor and is applied when the browser payload is converted back, so datatype="number" is what stops a numeric column arriving as strings.
- row_count / column_count
- Generates an empty grid of that size for forms; the browser then always submits that many rows, blanks included. `col_count` still works but is deprecated with two warnings.
- type
- What your function receives: "pandas" (default), "numpy", "polars" or "array" (list of lists). Verified: the same payload comes out as DataFrame, ndarray or list accordingly.
- value
- A DataFrame, a list of lists, a dict, a numpy array, a pandas Styler, or a path to a CSV. Empty list and blank row_count both start you at zero rows.
- grid.select(fn, None, out)
- Cell click. Your handler takes a gr.SelectData and never appears in the inputs list — pass None there. index / value / row_value / col_value / selected.
History
It shipped twice
gr.Dataframe arrived in 1.1.0 (2020-08-10) as two separate classes: `gradio/inputs.py::Dataframe(InputComponent)` and `gradio/outputs.py::Dataframe(OutputComponent)`. The split made sense in a library built on gr.Interface, where a component was one or the other, and it limped along until 3.0.1 (2022-05-16), when the two were merged into a single `components.py::Dataframe(Changeable, IOComponent)`. I confirmed both ends by untarring the PyPI sdists: 1.0.0 has no Dataframe at all, 1.1.0 has the input/output pair, 3.0.1 has one class with two thin subclass shims kept for backwards compatibility.
Then it learned to listen
The component sat read-only-ish for years, and interactivity arrived in pieces. `.select()` landed in 3.21.0 (2023-03-14) via a release note that also introduced the whole SelectData mechanism across Chatbot, Dropdown, Gallery, Radio and friends. `.edit()` — the event for a cell actually being committed — only arrived in 5.46.1 (2025-09-19), PR #11648. That is 920 days between being able to click a cell and being able to know it was changed, which is a fair summary of how the table component grew: slowly, then all at once (search, pinned columns and static columns all appeared within 2025).
Fun facts
Pros & cons
pros
- + The only core component that returns a typed pandas DataFrame — no glue code between the UI and your existing data pipeline
- + Real spreadsheet affordances out of the box: sortable columns, cell editing, boolean checkboxes, date pickers, and a fullscreen button on big grids
- + One component covers both jobs: a formatted results board for outputs and a blank 6x4 form for inputs, chosen just by the `interactive` flag
cons
- − Editability is all-or-nothing per component, and clicking a cell to edit also fires `.select()` — you cannot have one without the other
- − The event surface overlaps violently: one committed edit calls three different listeners twice each, with no documented contract for the doubling
- − Limits are half-built — `row_limits` and `column_limits` are accepted but warn 'not yet implemented', and `col_count` is a deprecated alias for `column_count` — so validation is on you
Takeaways
- 1Pass interactive=True or your table is decoration: with no `interactive` kwarg, or with False, the browser opens no editor and your function never sees typed data.
- 2Blank cells arrive as NaN, not empty strings — count them with df.isna() instead of comparing to '', and row_count=N always submits N rows.
- 3Wire exactly one of edit / change / input to your expensive function; a single Enter fires all three, twice each, in that order.
- 4In a `.select()` handler read `ev.col_value` as the column of values and `ev.value` as the cell — the docstring and the runtime disagree about col_value, the runtime wins.
- 5Use `column_count`, not `col_count`; the old name still works but logs two deprecation warnings on every construction, and the modern-looking `row_limits` / `column_limits` are not implemented yet.