How-to
Getting Started With Python Excel AutomationDeep dive

Convert an Excel Sheet to an HTML Table with Python

Turn a worksheet into clean HTML for a web page, an email or an intranet report — pandas to_html, number and date formatting, Styler for highlights, captions and accessible headers, and several sheets on one page.

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.

From worksheet to HTML table A worksheet is read into a DataFrame with read_excel, values are formatted as display strings, the table is rendered with to_html or Styler.to_html with CSS classes, and the HTML is saved as a page or placed in an email body. Worksheet read_excel Format values numbers, dates Render to_html / Styler Publish page or email HTML carries the values and the structure — the look comes from CSS you control

Prerequisites

Bash
pip install pandas openpyxl jinja2

jinja2 is needed only for Styler. The examples start from a small regional report:

Python
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:

Python
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:

Python
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.

Raw values and the formatted cells in the HTML table Without formatters, revenue appears as 1234567.891, growth as 0.1234 and the date with a midnight timestamp. With formatters, the same values render as 1,234,568, plus 12.3 percent and 01 Sep 2026, while the DataFrame keeps the raw numbers. A B C 1 Revenue Growth Updated 2 1234567.891 0.1234 2026-09-01 00:00:00 A B C 1 Revenue Growth Updated 2 1,234,568 +12.3% 01 Sep 2026 to_html() with formatters formatters

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:

Python
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:

Python
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:

Python
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:

Python
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.

Choosing how to render the HTML table For a web page, use to_html with a CSS class and style it from the page's stylesheet, or Styler when cell colours depend on values. For an email body, use inline styles because many mail clients remove style blocks. Where will the table be read? a web page to_html + CSS class styled by the page value-based colours Styler.to_html generated style block an email inline styles survive every client

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:

Python
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=False lets 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. Pass na_rep="" or na_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 with header=[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.

More on file formats & conversion

More on file formats & conversion →