Convert an Excel Sheet to an HTML Table with Python
A worksheet is often the data behind something that is read in a browser or an inbox: a status table on an intranet page, a summary in the body of a report email, a static page generated every night. pandas converts a DataFrame to an HTML <table> in one call, and a few options decide whether the result looks like a finished table or a raw dump. This guide, part of Handling Excel File Formats and Conversions, covers the plain conversion, number formatting, conditional highlights with Styler, accessibility, and putting several sheets on one page.
Prerequisites
pip install pandas openpyxl jinja2
jinja2 is needed only for Styler. The examples start from a small regional report:
import pandas as pd
pd.DataFrame({
"Region": ["North", "South", "East", "West"],
"Revenue": [1234567.891, 987654.3, 1543210.0, 456789.12],
"Growth": [0.1234, -0.0342, 0.081, -0.12],
"Updated": pd.to_datetime(["2026-09-01", "2026-09-01", "2026-09-02", "2026-09-02"]),
}).to_excel("regions.xlsx", sheet_name="Summary", index=False)
df = pd.read_excel("regions.xlsx", sheet_name="Summary")
Step 1 — The plain conversion
to_html returns a string containing a <table>. Two options are almost always right: index=False, because the row numbers mean nothing to a reader, and a CSS class so the page can style the table:
html = df.to_html(index=False, classes="report", border=0)
print(html[:160])
The output has a <thead> with the column names as <th> cells and a <tbody> of rows — correct structure — but the values are raw: 1234567.891, 0.1234, 2026-09-01 00:00:00. Nobody wants to read those.
Step 2 — Format numbers and dates
Pass a formatters dict mapping each column to a function that returns the display string. The DataFrame keeps its real values; only the HTML changes:
formatters = {
"Revenue": lambda v: f"{v:,.0f}",
"Growth": lambda v: f"{v:+.1%}",
"Updated": lambda d: d.strftime("%d %b %Y"),
}
html = df.to_html(index=False, classes="report", border=0, formatters=formatters)
print(html.split("<tbody>")[1].split("</tr>")[0])
The first row now reads 1,234,568, +12.3% and 01 Sep 2026. The formats deliberately mirror Excel's number formats — thousands separators, signed percentages — so the table reads like the sheet it came from. When the workbook already defines formats per cell and you want exactly what Excel shows, read the displayed text instead, as described in Read Cell Values as Displayed in Excel with Python.
Step 3 — Highlight values with Styler
For colour that depends on the data — negative growth in red, the best region in bold — use df.style. Each method adds CSS to individual cells, and format applies display formats like the formatters above:
def negative_red(v):
return "color: #b91c1c; font-weight: 600;" if v < 0 else ""
styled = (
df.style
.format({"Revenue": "{:,.0f}", "Growth": "{:+.1%}", "Updated": "{:%d %b %Y}"})
.map(negative_red, subset=["Growth"])
.highlight_max(subset=["Revenue"], props="font-weight: 700;")
.hide(axis="index")
.set_table_attributes('class="report"')
)
styled_html = styled.to_html()
print("color: #b91c1c" in styled_html, 'class="report"' in styled_html) # True True
Styler writes the per-cell styles as a <style> block with generated ids plus the table. That works on web pages; for email, where many clients discard <style> blocks, see Step 5.
Step 4 — Make the table accessible
Screen readers rely on table structure. Add a caption that says what the table is, and make sure column headers are <th> elements with scope="col". Styler handles the caption; a small regular expression adds the scope attribute to every column-heading cell, whatever other attributes Styler has put on it:
import re
accessible = re.sub(
r'<th (?=[^>]*class="col_heading)',
'<th scope="col" ',
styled.set_caption("Revenue and growth by region, September 2026").to_html(),
)
print("<caption>" in accessible, 'scope="col"' in accessible) # True True
Keep the caption meaningful — "Revenue and growth by region" rather than "Table 1" — and avoid merged header cells in HTML output; a flat header row is both easier to style and easier for assistive technology to announce.
Step 5 — Inline styles for email bodies
Email clients vary wildly in their CSS support, and several strip <style> blocks entirely. For an email body, put the styles directly on the elements with plain to_html and a little string work, which survives every client:
TH = "text-align:left;padding:6px 10px;border-bottom:2px solid #4338ca;"
TD = "padding:6px 10px;border-bottom:1px solid #cdd5e6;"
email_html = (
df.to_html(index=False, border=0, formatters=formatters, escape=True)
.replace('<table class="dataframe">', '<table style="border-collapse:collapse;font-family:Arial,sans-serif;font-size:14px;">')
.replace("<th>", f'<th style="{TH}">')
.replace("<td>", f'<td style="{TD}">')
)
print(email_html[:120])
The result renders consistently in Outlook, Gmail and mobile clients. Sending it as the HTML part of a message is covered in Email an Excel Report with an HTML Summary Body.
Step 6 — Several sheets on one page
To publish a whole workbook as one page, read every sheet with sheet_name=None, render each as a section with its own heading and anchor, and wrap them in a minimal HTML document with a stylesheet:
from html import escape
sheets = pd.read_excel("regions.xlsx", sheet_name=None)
sections, links = [], []
for i, (name, frame) in enumerate(sheets.items(), start=1):
anchor = f"sheet-{i}"
links.append(f'<li><a href="#{anchor}">{escape(name)}</a></li>')
sections.append(f'<section id="{anchor}"><h2>{escape(name)}</h2>'
f'{frame.to_html(index=False, classes="report", border=0)}</section>')
page = f"""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Regional report</title>
<style>
body {{ font-family: system-ui, sans-serif; margin: 2rem; }}
table.report {{ border-collapse: collapse; }}
table.report th, table.report td {{ padding: .4rem .8rem; border-bottom: 1px solid #cdd5e6; text-align: left; }}
</style></head>
<body><h1>Regional report</h1><ul>{''.join(links)}</ul>{''.join(sections)}</body></html>"""
with open("regions.html", "w", encoding="utf-8") as fh:
fh.write(page)
print(len(sheets), "sheet(s) written to regions.html")
Sheet names go through html.escape because they are text from the workbook; to_html already escapes cell values. The anchors let readers jump to a sheet, which matters once a workbook has more than two or three.
Step 7 — Right-align numbers and add a total row
Numbers are easier to compare when their digits line up, so right-align numeric columns, and give the reader the total they would otherwise work out in their head. Add the total as a formatted row before rendering, and mark it with a class so the stylesheet can make it bold:
total = pd.DataFrame([{"Region": "Total", "Revenue": df["Revenue"].sum(),
"Growth": None, "Updated": df["Updated"].max()}])
with_total = pd.concat([df, total], ignore_index=True)
table = with_total.to_html(index=False, classes="report", border=0, na_rep="",
formatters={**formatters, "Growth": lambda v: "" if pd.isna(v) else f"{v:+.1%}"})
table = table.replace("<tr>\n <td>Total</td>", '<tr class="total">\n <td>Total</td>')
print('class="total"' in table) # True
In the page stylesheet, table.report td:nth-child(2), table.report td:nth-child(3) { text-align: right; font-variant-numeric: tabular-nums; } aligns the figures, and tr.total td { font-weight: 700; border-top: 2px solid; } sets off the total. Wide tables also need a wrapper such as <div style="overflow-x:auto"> around them, so that on a phone the table scrolls sideways instead of stretching the whole page.
Common pitfalls
- Turning off escaping for data.
escape=Falselets a cell value like<img src=x onerror=…>become live markup. Keep escaping on for anything read from a workbook. - Losing leading zeros. Codes read as numbers lose their zeros before they ever reach HTML. Read identifier columns with
dtype=str. - NaN in the output. Missing values render as
NaN. Passna_rep=""orna_rep="–"to show a blank or a dash. - Dates written as text in the workbook. A date typed as text arrives as a string, and the date formatter then fails on it. Convert with
pd.to_datetime(..., errors="coerce")before formatting, so every value in the column is a real date or clearly missing. - Styler ids that change on every run. Styler generates random ids for its style block, so the HTML differs byte for byte between runs even when the data is identical. Call
df.style.set_uuid("report")to fix the prefix when you compare, cache or version-control the generated HTML. - Merged header cells. A sheet with merged group headings above the column names reads into pandas as a two-row header full of
Unnamed: 1-style labels. Read withheader=[0, 1]for a real two-level header, or skip the group row and name the columns yourself, before converting. - Huge tables. A 50,000-row table makes a slow, unusable page. Publish a summary and link to the full workbook for download.
Conclusion
to_html(index=False, classes=...) gives correct table structure; formatters turns raw numbers and dates into readable text without changing the data; Styler adds value-based colours for web pages; inline styles make the same table safe for email. Add a caption and header scope for accessibility, keep escaping on, and render multi-sheet workbooks as sections with anchors on one page.
Frequently asked questions
How do I convert an Excel sheet to HTML in Python? Read it with pd.read_excel and call to_html on the DataFrame. Pass index=False to drop the row numbers, and formatters or float_format to control how numbers appear.
Will the HTML keep the colours and fonts from Excel? No. to_html converts values, not cell formatting. Re-create the formatting that matters with CSS classes or with pandas Styler, which lets you colour cells based on their values.
Is to_html safe for values that contain HTML? Yes, by default. to_html escapes characters like < and & so a value such as
How do I put the table in an email? Build the HTML with to_html or Styler.to_html, use inline styles rather than a stylesheet because many mail clients strip style blocks, and send it as the HTML part of the message.
Related
- Up one level: Handling Excel File Formats and Conversions — every format in and out.
- Convert an Excel Sheet to JSON with Python — the other web-facing format.
- Export a Styled pandas DataFrame to Excel — Styler going the other way.
- Serving Excel Files from Python Web Apps — offering the workbook for download beside the table.
More on file formats & conversion
- How-toConvert Excel Files to Parquet with Python
- How-toConvert Excel to CSV with Python
- ComparisonExcel vs CSV vs Parquet for Python Data Pipelines
- ComparisonPick an Excel Engine for .xlsx, .xlsm, .xls, .xlsb and .ods
- FixFix Garbled Characters Like "Café" and "’" in Excel Data
- How-toExtract VBA Macro Code from an Excel File