Create an Excel Table with Python
A table is what turns a range of cells into an object Excel understands: it has a name, banded formatting, its own filter, and structured references such as Orders[Revenue] that keep working when rows are added. It is also stricter than a plain filter, and that strictness is behind most of the "unreadable content" dialogs people meet. This guide, part of Creating Excel Tables and Autofilters with Python, builds one correctly.
Prerequisites
pip install pandas openpyxl
Create the table
import pandas as pd
from openpyxl import load_workbook
from openpyxl.utils import get_column_letter
from openpyxl.worksheet.table import Table, TableStyleInfo
df = pd.DataFrame({
"Order_ID": [2001, 2002, 2003, 2004],
"Region": ["North", "South", "West", "Central"],
"Quantity": [4, 2, 7, 5],
"Unit_Price": [19.99, 49.50, 12.25, 8.75],
})
df["Revenue"] = (df["Quantity"] * df["Unit_Price"]).round(2)
with pd.ExcelWriter("orders.xlsx", engine="openpyxl") as writer:
df.to_excel(writer, sheet_name="Orders", index=False)
wb = load_workbook("orders.xlsx")
ws = wb["Orders"]
ref = f"A1:{get_column_letter(ws.max_column)}{ws.max_row}"
table = Table(displayName="Orders", ref=ref)
table.tableStyleInfo = TableStyleInfo(
name="TableStyleMedium9",
showFirstColumn=False,
showLastColumn=False,
showRowStripes=True,
showColumnStripes=False,
)
ws.add_table(table)
ws.freeze_panes = "A2"
wb.save("orders_table.xlsx")
print("tables on the sheet:", list(ws.tables))
The table brings its own filter dropdowns, so there is no need to set auto_filter as well. Style names follow Excel's built-in set — TableStyleLight1 through TableStyleDark11 — and Medium9 is the blue banded style most reports use.
The rules that prevent a repair dialog
Excel validates a table on open, and it does not explain what it found. Three conditions cover almost every failure:
import re
from openpyxl.utils import get_column_letter
NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def check_table_inputs(ws, name, first_row=1):
problems = []
if not NAME_RE.match(name):
problems.append(f"displayName {name!r} must be letters, digits and underscores only")
headers = [ws.cell(row=first_row, column=c).value for c in range(1, ws.max_column + 1)]
blanks = [get_column_letter(i + 1) for i, h in enumerate(headers) if h in (None, "")]
if blanks:
problems.append(f"blank header(s) in column(s) {blanks}")
lowered = [str(h).strip().lower() for h in headers if h not in (None, "")]
dupes = {h for h in lowered if lowered.count(h) > 1}
if dupes:
problems.append(f"duplicate header(s): {sorted(dupes)}")
return problems
issues = check_table_inputs(ws, "Orders")
if issues:
raise ValueError("cannot build a table — " + "; ".join(issues))
Duplicate headers are compared case-insensitively, so Region and region collide. Blank headers appear whenever a DataFrame was written with an unnamed index column or with a stray empty column at the edge — writing with index=False removes the commonest source.
The fourth condition is the ref itself: it must end at the last row that holds data. A range extending into empty rows produces a table whose bottom rows are blank, and Excel treats that as corruption rather than as a design choice.
Structured references
The name is the point of the whole exercise. Formulas written against the table survive rows being added, because the table's range moves with them:
from openpyxl import load_workbook
wb = load_workbook("orders_table.xlsx")
ws = wb["Orders"]
total_row = ws.max_row + 2
ws[f"D{total_row}"] = "Total revenue"
ws[f"E{total_row}"] = "=SUM(Orders[Revenue])"
ws[f"E{total_row}"].number_format = "#,##0.00"
ws[f"D{total_row + 1}"] = "North revenue"
ws[f"E{total_row + 1}"] = '=SUMIF(Orders[Region],"North",Orders[Revenue])'
ws[f"E{total_row + 1}"].number_format = "#,##0.00"
wb.save("orders_table_totals.xlsx")
=SUM(Orders[Revenue]) reads better than =SUM(E2:E5) and, more importantly, keeps working when the table grows. Column names inside the brackets must match the header text exactly, including spaces — which is one reason to prefer underscore-separated headers in generated reports.
Extending the table when rows are added
Excel extends a table automatically when a person types under it. openpyxl does not, so a job that appends rows must rewrite ref:
from openpyxl import load_workbook
from openpyxl.utils import get_column_letter
new_rows = [(2005, "North", 3, 19.99, 59.97), (2006, "West", 6, 12.25, 73.50)]
wb = load_workbook("orders_table.xlsx")
ws = wb["Orders"]
table = ws.tables["Orders"]
for row in new_rows:
ws.append(list(row))
table.ref = f"A1:{get_column_letter(ws.max_column)}{ws.max_row}"
wb.save("orders_table_extended.xlsx")
print("new ref:", ws.tables["Orders"].ref)
Forgetting the last two lines produces the subtle version of this bug: the rows are in the sheet, the banding stops short of them, and SUM(Orders[Revenue]) quietly excludes them. A summary that under-reports by two rows is much harder to notice than one that fails outright.
Inspect the tables in an existing workbook
Before touching a file someone else built, look at what is defined:
from openpyxl import load_workbook
wb = load_workbook("orders_table_extended.xlsx")
for ws in wb.worksheets:
for name, table in ws.tables.items():
last_ref_row = int("".join(ch for ch in table.ref.split(":")[1] if ch.isdigit()))
status = "current" if last_ref_row == ws.max_row else f"stale (data ends at {ws.max_row})"
print(f"{ws.title}: {name} ref={table.ref} style={table.tableStyleInfo.name} — {status}")
Stale ranges are common in templates that have been in use for a year or two, and they are invisible in Excel unless you look at the table's borders. Refreshing every table's ref from ws.max_row as part of the job is a two-line habit that keeps a delivered workbook trustworthy.
Table or plain filter?
Both give readers dropdowns; they differ in what else they bring and what they demand:
The last demand is the one that decides most cases. A sheet with subtotal rows between groups, a spacer above a total, or two blocks stacked on one sheet is not a table — those layouts are perfectly good reports and perfectly bad tables, and a plain autofilter over the data range serves them better. Reach for a table when the sheet is one clean rectangle of records, which is exactly what a to_excel export usually is.
Common pitfalls and gotchas
| Symptom | Cause | Fix |
|---|---|---|
| "Excel found unreadable content" | Blank or duplicate header, or a bad ref | Validate headers and build ref from ws.max_row |
ValueError: Table name is already in use | Duplicate displayName in the workbook | Names are workbook-wide, not per sheet |
| Banding stops short of the data | ref not updated after appending | Rewrite table.ref after ws.append |
Structured reference returns #NAME? | Table name or column name misspelled | Match the header text exactly |
| Table lost after a rewrite | to_excel replaced the sheet | Add the table after writing the data |
| Style ignored | tableStyleInfo never assigned | Attach a TableStyleInfo to the table |
| Extra blank column inside the table | DataFrame index written out | to_excel(..., index=False) |
Several tables on one sheet
A sheet can hold more than one table, provided their ranges do not touch and each has its own name. It is the natural layout for a summary block above a detail block:
from openpyxl import load_workbook
from openpyxl.worksheet.table import Table, TableStyleInfo
wb = load_workbook("orders_table.xlsx")
ws = wb["Orders"]
start = ws.max_row + 3 # leave a clear gap between the two
ws.cell(row=start, column=1, value="Region")
ws.cell(row=start, column=2, value="Revenue")
for offset, (region, revenue) in enumerate([("North", 179.91), ("West", 85.75)], start=1):
ws.cell(row=start + offset, column=1, value=region)
ws.cell(row=start + offset, column=2, value=revenue)
summary = Table(displayName="RegionSummary", ref=f"A{start}:B{start + 2}")
summary.tableStyleInfo = TableStyleInfo(name="TableStyleLight9", showRowStripes=True)
ws.add_table(summary)
wb.save("two_tables.xlsx")
print(list(ws.tables))
Overlapping ranges are the failure here, and Excel reports them as the same unreadable-content error as every other table problem. Leaving at least one blank row between blocks keeps them apart and reads better anyway.
Performance and scale notes
A table is a small amount of XML plus a style reference, so it costs nothing to add regardless of the row count. What does cost is the reader's experience: banded formatting on 200,000 rows makes scrolling noticeably heavier in Excel, and the filter dropdowns on a table that large are slow to open. For very big sheets, prefer a plain autofilter without banding, or split the detail from the summary as the large-file guide suggests.
Name tables after what they hold
A table's displayName appears in every structured reference, so Orders[Revenue] reads well and Table1[Column3] does not. Because the name must also be unique across the workbook, deriving it from the sheet's purpose rather than from a counter keeps a multi-sheet report legible to whoever writes formulas against it later.
Conclusion
A table is three things — a valid displayName, a ref that matches the data exactly, and a TableStyleInfo — attached with ws.add_table after the data has been written. Validate the headers before you build it, rewrite ref whenever rows are appended, and use the name in formulas so summaries follow the table as it grows. Those habits turn the repair dialog from a recurring mystery into something you never see.
Frequently asked questions
Why does Excel offer to repair my file?
The table definition does not match the sheet. The usual causes are a blank or duplicate header, a displayName containing a space, or a ref that covers rows beyond the data.
What are the rules for displayName?
The same as for a defined name — letters, digits and underscores, starting with a letter or underscore, no spaces, and unique within the workbook.
Do I still need an autofilter?
No. A table includes its own filter dropdowns, so adding auto_filter as well is redundant.
How do I make the table cover new rows?
Rewrite table.ref from ws.max_row after appending. Excel extends a table when a user types beneath it, but openpyxl does not.
Related
Up to the parent guide:
- Creating Excel Tables and Autofilters with Python — tables, filters, grouping and protection.
Related guides:
- Add an Autofilter to an Excel Sheet with openpyxl — the lighter alternative.
- Sort Excel Rows with Python Before Writing — the order inside the table.
- Create a Named Range in Excel with openpyxl — the naming rules a table shares.
- Copy a Formula Down a Column in openpyxl — filling a calculated column inside a table.