Repository: gradio-app/gradio

Issue:
Empty `pd.DataFrame` is converted to a dataframe with a single row
### Describe the bug

When an empty `pd.DataFrame` is passed to `gr.Dataframe`, it gets converted to a `pd.DataFrame` with one row.
This might be partially related to https://github.com/gradio-app/gradio/pull/10476. Sorry if I overlooked this during the review.
(This issue seems to have existed even before `gradio==5.12.0`.)

In the following repro, the shape of `df_orig` is `(0, 2)`, but once it's passed to `gr.Dataframe`, its shape becomes `(1, 2)`.
This can be problematic when the table becomes empty due to filtering, as an empty table and a table with a single empty row are not the same.

### Have you searched existing issues?  🔎

- [x] I have searched and found no existing issues

### Reproduction

```python
import gradio as gr
import pandas as pd

df_orig = pd.DataFrame(columns=["a", "b"])
print(df_orig)
print(df_orig.shape)


def fn(df):
    print(df)
    print(df.shape)
    return df.shape


with gr.Blocks() as demo:
    df = gr.Dataframe(value=df_orig)
    btn = gr.Button()
    out = gr.Textbox()
    btn.click(fn=fn, inputs=df, outputs=out)
demo.launch()
```

### Screenshot

![](https://github.com/user-attachments/assets/a3a999bf-e90e-48e3-b14f-4292c2ebaaa4)

### Logs

```shell

```

### System Info

```shell
gradio==5.16.0
```

### Severity

I can work around it

Discussion:

Solution patch:
diff --git a/.changeset/cyan-clouds-watch.md b/.changeset/cyan-clouds-watch.md
new file mode 100644
index 000000000..ed14d7c62
--- /dev/null
+++ b/.changeset/cyan-clouds-watch.md
@@ -0,0 +1,6 @@
+---
+"@gradio/dataframe": patch
+"gradio": patch
+---
+
+fix:Add empty dataframe functionality
diff --git a/gradio/components/dataframe.py b/gradio/components/dataframe.py
index 06159ca1c..f8097e040 100644
--- a/gradio/components/dataframe.py
+++ b/gradio/components/dataframe.py
@@ -417,11 +417,9 @@ class Dataframe(Component):
             )
 
         headers = self.get_headers(value) or self.headers
-        data = (
-            [["" for _ in range(len(headers))]]
-            if self.is_empty(value)
-            else self.get_cell_data(value)
-        )
+        data = [] if self.is_empty(value) else self.get_cell_data(value)
+        if len(data) == 0:
+            return DataframeData(headers=headers, data=[], metadata=None)
         if len(headers) > len(data[0]):
             headers = headers[: len(data[0])]
         elif len(headers) < len(data[0]):
diff --git a/js/dataframe/Example.svelte b/js/dataframe/Example.svelte
index 0ae0ac9d5..b6ceabd91 100644
--- a/js/dataframe/Example.svelte
+++ b/js/dataframe/Example.svelte
@@ -6,6 +6,7 @@
 
 	let hovered = false;
 	let loaded = Array.isArray(value);
+	let is_empty = loaded && (value.length === 0 || value[0].length === 0);
 </script>
 
 {#if loaded}
@@ -20,6 +21,12 @@
 	>
 		{#if typeof value === "string"}
 			{value}
+		{:else if is_empty}
+			<table class="">
+				<tr>
+					<td>Empty</td>
+				</tr>
+			</table>
 		{:else}
 			<table class="">
 				{#each value.slice(0, 3) as row, i}
diff --git a/js/dataframe/shared/CellMenu.svelte b/js/dataframe/shared/CellMenu.svelte
index 085b7b24e..03127f7be 100644
--- a/js/dataframe/shared/CellMenu.svelte
+++ b/js/dataframe/shared/CellMenu.svelte
@@ -89,7 +89,7 @@
 <style>
 	.cell-menu {
 		position: fixed;
-		z-index: var(--layer-2);
+		z-index: var(--layer-4);
 		background: var(--background-fill-primary);
 		border: 1px solid var(--border-color-primary);
 		border-radius: var(--radius-sm);
diff --git a/js/dataframe/shared/EditableCell.svelte b/js/dataframe/shared/EditableCell.svelte
index 6c5179b9d..7fa4d91ae 100644
--- a/js/dataframe/shared/EditableCell.svelte
+++ b/js/dataframe/shared/EditableCell.svelte
@@ -110,7 +110,6 @@
 	class:multiline={header}
 	on:focus|preventDefault
 	style={styling}
-	class="table-cell-text"
 	data-editable={editable}
 	placeholder=" "
 >
@@ -149,8 +148,6 @@
 		position: relative;
 		display: inline-block;
 		outline: none;
-		padding: var(--size-2);
-		padding-right: 0;
 		-webkit-user-select: text;
 		-moz-user-select: text;
 		-ms-user-select: text;
@@ -177,6 +174,7 @@
 		font-weight: var(--weight-bold);
 		white-space: normal;
 		word-break: break-word;
+		margin-left: var(--size-1);
 	}
 
 	.edit {
diff --git a/js/dataframe/shared/Table.svelte b/js/dataframe/shared/Table.svelte
index a0816d972..a712f0927 100644
--- a/js/dataframe/shared/Table.svelte
+++ b/js/dataframe/shared/Table.svelte
@@ -176,15 +176,14 @@
 		id: string;
 	}[][] {
 		const data_row_length = _values.length;
+		if (data_row_length === 0) return [];
 		return Array(row_count[1] === "fixed" ? row_count[0] : data_row_length)
 			.fill(0)
 			.map((_, i) => {
 				return Array(
 					col_count[1] === "fixed"
 						? col_count[0]
-						: data_row_length > 0
-							? _values[0].length
-							: headers.length
+						: _values[0].length || headers.length
 				)
 					.fill(0)
 					.map((_, j) => {
@@ -473,12 +472,8 @@
 		parent.focus();
 
 		if (row_count[1] !== "dynamic") return;
-		if (data.length === 0) {
-			values = [Array(headers.length).fill("")];
-			return;
-		}
 
-		const new_row = Array(data[0].length)
+		const new_row = Array(data[0]?.length || headers.length)
 			.fill(0)
 			.map((_, i) => {
 				const _id = make_id();
@@ -486,7 +481,9 @@
 				return { id: _id, value: "" };
 			});
 
-		if (index !== undefined && index >= 0 && index <= data.length) {
+		if (data.length === 0) {
+			data = [new_row];
+		} else if (index !== undefined && index >= 0 && index <= data.length) {
 			data.splice(index, 0, new_row);
 		} else {
 			data.push(new_row);
@@ -786,15 +783,17 @@
 	async function delete_col(index: number): Promise<void> {
 		parent.focus();
 		if (col_count[1] !== "dynamic") return;
-		if (data[0].length <= 1) return;
+		if (_headers.length <= 1) return;
 
 		_headers.splice(index, 1);
 		_headers = _headers;
 
-		data.forEach((row) => {
-			row.splice(index, 1);
-		});
-		data = data;
+		if (data.length > 0) {
+			data.forEach((row) => {
+				row.splice(index, 1);
+			});
+			data = data;
+		}
 		selected = false;
 	}
 
@@ -1066,184 +1065,205 @@
 			bind:dragging
 			aria_label={i18n("dataframe.drop_to_upload")}
 		>
-			<VirtualTable
-				bind:items={data}
-				{max_height}
-				bind:actual_height={table_height}
-				bind:table_scrollbar_width={scrollbar_width}
-				selected={selected_index}
-				disable_scroll={active_cell_menu !== null ||
-					active_header_menu !== null}
-			>
-				{#if label && label.length !== 0}
-					<caption class="sr-only">{label}</caption>
-				{/if}
-				<tr slot="thead">
-					{#if show_row_numbers}
-						<th
-							class="row-number-header frozen-column always-frozen"
-							style="left: 0;"
-						>
-							<div class="cell-wrap">
-								<div class="header-content">
-									<div class="header-text"></div>
-								</div>
-							</div>
-						</th>
+			<div class="table-wrap">
+				<VirtualTable
+					bind:items={data}
+					{max_height}
+					bind:actual_height={table_height}
+					bind:table_scrollbar_width={scrollbar_width}
+					selected={selected_index}
+					disable_scroll={active_cell_menu !== null ||
+						active_header_menu !== null}
+				>
+					{#if label && label.length !== 0}
+						<caption class="sr-only">{label}</caption>
 					{/if}
-					{#each _headers as { value, id }, i (id)}
-						<th
-							class:frozen-column={i < actual_pinned_columns}
-							class:last-frozen={i === actual_pinned_columns - 1}
-							class:focus={header_edit === i || selected_header === i}
-							aria-sort={get_sort_status(value, sort_by, sort_direction)}
-							style="width: {get_cell_width(i)}; left: {i <
-							actual_pinned_columns
-								? i === 0
-									? show_row_numbers
-										? 'var(--cell-width-row-number)'
-										: '0'
-									: `calc(${show_row_numbers ? 'var(--cell-width-row-number) + ' : ''}${Array(
-											i
-										)
-											.fill(0)
-											.map((_, idx) => `var(--cell-width-${idx})`)
-											.join(' + ')})`
-								: 'auto'};"
-							on:click={() => {
-								toggle_header_button(i);
-							}}
-						>
-							<div class="cell-wrap">
-								<div class="header-content">
+					<tr slot="thead">
+						{#if show_row_numbers}
+							<th
+								class="row-number-header frozen-column always-frozen"
+								style="left: 0;"
+							>
+								<div class="cell-wrap">
+									<div class="header-content">
+										<div class="header-text"></div>
+									</div>
+								</div>
+							</th>
+						{/if}
+						{#each _headers as { value, id }, i (id)}
+							<th
+								class:frozen-column={i < actual_pinned_columns}
+								class:last-frozen={i === actual_pinned_columns - 1}
+								class:focus={header_edit === i || selected_header === i}
+								aria-sort={get_sort_status(value, sort_by, sort_direction)}
+								style="width: {get_cell_width(i)}; left: {i <
+								actual_pinned_columns
+									? i === 0
+										? show_row_numbers
+											? 'var(--cell-width-row-number)'
+											: '0'
+										: `calc(${show_row_numbers ? 'var(--cell-width-row-number) + ' : ''}${Array(
+												i
+											)
+												.fill(0)
+												.map((_, idx) => `var(--cell-width-${idx})`)
+												.join(' + ')})`
+									: 'auto'};"
+								on:click={() => {
+									toggle_header_button(i);
+								}}
+							>
+								<div class="cell-wrap">
+									<div class="header-content">
+										<EditableCell
+											{max_chars}
+											bind:value={_headers[i].value}
+											bind:el={els[id].input}
+											{latex_delimiters}
+											{line_breaks}
+											edit={header_edit === i}
+											on:keydown={end_header_edit}
+											on:dblclick={() => edit_header(i)}
+											header
+											{root}
+											{editable}
+										/>
+										<div class="sort-buttons">
+											<SortIcon
+												direction={sort_by === i ? sort_direction : null}
+												on:sort={({ detail }) => handle_sort(i, detail)}
+												{i18n}
+											/>
+										</div>
+									</div>
+									{#if editable}
+										<button
+											class="cell-menu-button"
+											on:click={(event) => toggle_header_menu(event, i)}
+											on:touchstart={(event) => {
+												event.preventDefault();
+												const touch = event.touches[0];
+												const mouseEvent = new MouseEvent("click", {
+													clientX: touch.clientX,
+													clientY: touch.clientY,
+													bubbles: true,
+													cancelable: true,
+													view: window
+												});
+												toggle_header_menu(mouseEvent, i);
+											}}
+										>
+											&#8942;
+										</button>
+									{/if}
+								</div>
+							</th>
+						{/each}
+					</tr>
+					<tr slot="tbody" let:item let:index class:row_odd={index % 2 === 0}>
+						{#if show_row_numbers}
+							<td
+								class="row-number frozen-column always-frozen"
+								style="left: 0;"
+								tabindex="-1"
+							>
+								{index + 1}
+							</td>
+						{/if}
+						{#each item as { value, id }, j (id)}
+							<td
+								class:frozen-column={j < actual_pinned_columns}
+								class:last-frozen={j === actual_pinned_columns - 1}
+								tabindex={show_row_numbers && j === 0 ? -1 : 0}
+								bind:this={els[id].cell}
+								on:touchstart={(event) => {
+									const touch = event.touches[0];
+									const mouseEvent = new MouseEvent("click", {
+										clientX: touch.clientX,
+										clientY: touch.clientY,
+										bubbles: true,
+										cancelable: true,
+										view: window
+									});
+									handle_cell_click(mouseEvent, index, j);
+								}}
+								on:mousedown={(event) => {
+									event.preventDefault();
+									event.stopPropagation();
+								}}
+								on:click={(event) => handle_cell_click(event, index, j)}
+								style="width: {get_cell_width(j)}; left: {j <
+								actual_pinned_columns
+									? j === 0
+										? show_row_numbers
+											? 'var(--cell-width-row-number)'
+											: '0'
+										: `calc(${show_row_numbers ? 'var(--cell-width-row-number) + ' : ''}${Array(
+												j
+											)
+												.fill(0)
+												.map((_, idx) => `var(--cell-width-${idx})`)
+												.join(' + ')})`
+									: 'auto'}; {styling?.[index]?.[j] || ''}"
+								class:flash={copy_flash &&
+									is_cell_selected([index, j], selected_cells)}
+								class={is_cell_selected([index, j], selected_cells)}
+								class:menu-active={active_cell_menu &&
+									active_cell_menu.row === index &&
+									active_cell_menu.col === j}
+							>
+								<div class="cell-wrap">
 									<EditableCell
-										{max_chars}
-										bind:value={_headers[i].value}
+										bind:value={data[index][j].value}
 										bind:el={els[id].input}
+										display_value={display_value?.[index]?.[j]}
 										{latex_delimiters}
 										{line_breaks}
-										edit={header_edit === i}
-										on:keydown={end_header_edit}
-										on:dblclick={() => edit_header(i)}
-										header
-										{root}
 										{editable}
+										edit={dequal(editing, [index, j])}
+										datatype={Array.isArray(datatype) ? datatype[j] : datatype}
+										on:blur={() => {
+											clear_on_focus = false;
+											parent.focus();
+										}}
+										on:focus={() => {
+											const row = index;
+											const col = j;
+											if (
+												!selected_cells.some(([r, c]) => r === row && c === col)
+											) {
+												selected_cells = [[row, col]];
+											}
+										}}
+										{clear_on_focus}
+										{root}
+										{max_chars}
 									/>
-									<div class="sort-buttons">
-										<SortIcon
-											direction={sort_by === i ? sort_direction : null}
-											on:sort={({ detail }) => handle_sort(i, detail)}
-											{i18n}
-										/>
-									</div>
+									{#if editable && should_show_cell_menu([index, j], selected_cells, editable)}
+										<button
+											class="cell-menu-button"
+											on:click={(event) => toggle_cell_menu(event, index, j)}
+										>
+											&#8942;
+										</button>
+									{/if}
 								</div>
-								{#if editable}
-									<button
-										class="cell-menu-button"
-										on:click={(event) => toggle_header_menu(event, i)}
-									>
-										&#8942;
-									</button>
-								{/if}
-							</div>
-						</th>
-					{/each}
-				</tr>
-				<tr slot="tbody" let:item let:index class:row_odd={index % 2 === 0}>
-					{#if show_row_numbers}
-						<td
-							class="row-number frozen-column always-frozen"
-							style="left: 0;"
-							tabindex="-1"
-						>
-							{index + 1}
-						</td>
-					{/if}
-					{#each item as { value, id }, j (id)}
-						<td
-							class:frozen-column={j < actual_pinned_columns}
-							class:last-frozen={j === actual_pinned_columns - 1}
-							tabindex={show_row_numbers && j === 0 ? -1 : 0}
-							bind:this={els[id].cell}
-							on:touchstart={(event) => {
-								const touch = event.touches[0];
-								const mouseEvent = new MouseEvent("click", {
-									clientX: touch.clientX,
-									clientY: touch.clientY,
-									bubbles: true,
-									cancelable: true,
-									view: window
-								});
-								handle_cell_click(mouseEvent, index, j);
-							}}
-							on:mousedown={(event) => {
-								event.preventDefault();
-								event.stopPropagation();
-							}}
-							on:click={(event) => handle_cell_click(event, index, j)}
-							style="width: {get_cell_width(j)}; left: {j <
-							actual_pinned_columns
-								? j === 0
-									? show_row_numbers
-										? 'var(--cell-width-row-number)'
-										: '0'
-									: `calc(${show_row_numbers ? 'var(--cell-width-row-number) + ' : ''}${Array(
-											j
-										)
-											.fill(0)
-											.map((_, idx) => `var(--cell-width-${idx})`)
-											.join(' + ')})`
-								: 'auto'}; {styling?.[index]?.[j] || ''}"
-							class:flash={copy_flash &&
-								is_cell_selected([index, j], selected_cells)}
-							class={is_cell_selected([index, j], selected_cells)}
-							class:menu-active={active_cell_menu &&
-								active_cell_menu.row === index &&
-								active_cell_menu.col === j}
-						>
-							<div class="cell-wrap">
-								<EditableCell
-									bind:value={data[index][j].value}
-									bind:el={els[id].input}
-									display_value={display_value?.[index]?.[j]}
-									{latex_delimiters}
-									{line_breaks}
-									{editable}
-									edit={dequal(editing, [index, j])}
-									datatype={Array.isArray(datatype) ? datatype[j] : datatype}
-									on:blur={() => {
-										clear_on_focus = false;
-										parent.focus();
-									}}
-									on:focus={() => {
-										const row = index;
-										const col = j;
-										if (
-											!selected_cells.some(([r, c]) => r === row && c === col)
-										) {
-											selected_cells = [[row, col]];
-										}
-									}}
-									{clear_on_focus}
-									{root}
-									{max_chars}
-								/>
-								{#if editable && should_show_cell_menu([index, j], selected_cells, editable)}
-									<button
-										class="cell-menu-button"
-										on:click={(event) => toggle_cell_menu(event, index, j)}
-									>
-										&#8942;
-									</button>
-								{/if}
-							</div>
-						</td>
-					{/each}
-				</tr>
-			</VirtualTable>
+							</td>
+						{/each}
+					</tr>
+				</VirtualTable>
+			</div>
 		</Upload>
 	</div>
 </div>
+{#if data.length === 0 && editable && row_count[1] === "dynamic"}
+	<div class="add-row-container">
+		<button class="add-row-button" on:click={() => add_row()}>
+			<span>+</span>
+		</button>
+	</div>
+{/if}
 
 {#if active_cell_menu}
 	<CellMenu
@@ -1280,7 +1300,7 @@
 		on_delete_row={() => delete_row_at(active_cell_menu?.row ?? -1)}
 		on_delete_col={() => delete_col_at(active_header_menu?.col ?? -1)}
 		can_delete_rows={false}
-		can_delete_cols={data[0].length > 1}
+		can_delete_cols={_headers.length > 1}
 	/>
 {/if}
 
@@ -1302,8 +1322,6 @@
 	.table-wrap {
 		position: relative;
 		transition: 150ms;
-		border: 1px solid var(--border-color-primary);
-		border-radius: var(--table-radius);
 	}
 
 	.table-wrap.menu-open {
@@ -1336,6 +1354,12 @@
 		border-collapse: separate;
 	}
 
+	.table-wrap > :global(button) {
+		border: 1px solid var(--border-color-primary);
+		border-radius: var(--table-radius);
+		overflow: hidden;
+	}
+
 	div:not(.no-wrap) td {
 		overflow-wrap: anywhere;
 	}
@@ -1378,10 +1402,12 @@
 
 	th:first-child {
 		border-top-left-radius: var(--table-radius);
+		border-bottom-left-radius: var(--table-radius);
 	}
 
 	th:last-child {
 		border-top-right-radius: var(--table-radius);
+		border-bottom-right-radius: var(--table-radius);
 	}
 
 	th.focus,
@@ -1411,6 +1437,7 @@
 		display: flex;
 		align-items: center;
 		flex-shrink: 0;
+		order: -1;
 	}
 
 	.editing {
@@ -1419,17 +1446,24 @@
 
 	.cell-wrap {
 		display: flex;
-		align-items: flex-start;
+		align-items: center;
+		justify-content: flex-start;
 		outline: none;
 		min-height: var(--size-9);
 		position: relative;
-		height: auto;
+		height: 100%;
+		padding: var(--size-2);
+		box-sizing: border-box;
+		margin: 0;
+		gap: var(--size-1);
+		overflow: visible;
+		min-width: 0;
+		border-radius: var(--table-radius);
 	}
 
 	.header-content {
 		display: flex;
 		align-items: center;
-		justify-content: space-between;
 		overflow: hidden;
 		flex-grow: 1;
 		min-width: 0;
@@ -1437,7 +1471,6 @@
 		overflow-wrap: break-word;
 		word-break: normal;
 		height: 100%;
-		padding: var(--size-1);
 		gap: var(--size-1);
 	}
 
@@ -1467,7 +1500,8 @@
 		transform: translateY(-50%);
 	}
 
-	.cell-selected .cell-menu-button {
+	.cell-selected .cell-menu-button,
+	th:hover .cell-menu-button {
 		display: flex;
 		align-items: center;
 		justify-content: center;
@@ -1684,4 +1718,24 @@
 	.always-frozen {
 		z-index: var(--layer-3);
 	}
+
+	.add-row-container {
+		margin-top: var(--size-2);
+	}
+
+	.add-row-button {
+		width: 100%;
+		padding: var(--size-1);
+		background: transparent;
+		border: 1px dashed var(--border-color-primary);
+		border-radius: var(--radius-sm);
+		color: var(--body-text-color);
+		cursor: pointer;
+		transition: all 150ms;
+	}
+
+	.add-row-button:hover {
+		background: var(--background-fill-secondary);
+		border-style: solid;
+	}
 </style>


Test patch:
diff --git a/test/components/test_dataframe.py b/test/components/test_dataframe.py
index fec240763..de6919429 100644
--- a/test/components/test_dataframe.py
+++ b/test/components/test_dataframe.py
@@ -29,7 +29,7 @@ class TestDataframe:
         assert dataframe_input.get_config() == {
             "value": {
                 "headers": ["Name", "Age", "Member"],
-                "data": [["", "", ""]],
+                "data": [],
                 "metadata": None,
             },
             "_selectable": False,
@@ -79,7 +79,7 @@ class TestDataframe:
         assert dataframe_output.get_config() == {
             "value": {
                 "headers": ["1", "2", "3"],
-                "data": [["", "", ""]],
+                "data": [],
                 "metadata": None,
             },
             "_selectable": False,
@@ -124,12 +124,6 @@ class TestDataframe:
         postprocess
         """
         dataframe_output = gr.Dataframe()
-        output = dataframe_output.postprocess([]).model_dump()
-        assert output == {
-            "data": [["", "", ""]],
-            "headers": ["1", "2", "3"],
-            "metadata": None,
-        }
         output = dataframe_output.postprocess(np.zeros((2, 2))).model_dump()
         assert output == {
             "data": [[0, 0], [0, 0]],
@@ -380,6 +374,7 @@ class TestDataframe:
         assert df.is_empty(None)
         assert df.is_empty({})
         assert df.is_empty({"data": [], "headers": ["a", "b"]})
+        assert df.is_empty({"data": []})
         assert not df.is_empty({"data": [1, 2]})
         assert not df.is_empty([[1, 2], [3, 4]])
         assert not df.is_empty(pd.DataFrame({"a": [1, 2]}))
@@ -403,7 +398,6 @@ class TestDataframe:
         assert df.get_cell_data(test_data) == [[1, 2], [3, 4]]
         assert df.get_cell_data(test_df) == [[1, 2], [3, 4]]
         assert df.get_cell_data({"data": test_data}) == [[1, 2], [3, 4]]
-        assert df.get_cell_data(np.array([1, 2, 3])) == [[1], [2], [3]]
 
         styled_df = test_df.style
         styled_df.hide(axis=1, subset=["col2"])