Pandas to_html(): Tables, Files and a Styled Sales Report

DataFrame.to_html() renders a DataFrame as an HTML table. Return a table string for an existing page, or write it to a file. Choose the records and columns first, then control labels and number formatting. For a standalone report, add document markup and CSS around the table.

Python Pandas html output from DataFrame & using MySQL sample table as source by to_html()

Create an HTML Table String

Without a destination, to_html() returns a string containing a table, not a complete HTML document. By default it includes column headings and row index labels. These student scores use the same small dataset throughout the basic examples.

import pandas as pd
from pathlib import Path
from io import StringIO

folder = Path("pandas_html_practice")
folder.mkdir(exist_ok=True)

def student_data():
    return pd.DataFrame({"NAME": ["Ravi", "Raju", "Alex"], "ID": [1, 2, 3],
                         "MATH": [30, 40, 50], "ENGLISH": [20, 30, 41]})

df = student_data()
table_html = df.to_html()
print(table_html)
assert isinstance(table_html, str)
assert table_html.count("<tr") == 4

Expected output

<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: right;">
      <th></th>
      <th>NAME</th>
      <th>ID</th>
      <th>MATH</th>
      <th>ENGLISH</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th>0</th>
      <td>Ravi</td>
      <td>1</td>
      <td>30</td>
      <td>20</td>
    </tr>
    <tr>
      <th>1</th>
      <td>Raju</td>
      <td>2</td>
      <td>40</td>
      <td>30</td>
    </tr>
    <tr>
      <th>2</th>
      <td>Alex</td>
      <td>3</td>
      <td>50</td>
      <td>41</td>
    </tr>
  </tbody>
</table>

Save a Table to a File or Buffer

With a path or writable buffer, to_html writes the table and returns None. Create the parent directory first. This relative path works in Colab and on a local computer; a Colab runtime cannot write directly to your Windows D drive. Read the UTF-8 file back to verify it.

df = student_data()
path = folder / "students.html"
result = df.to_html(path, index=False, encoding="utf-8")
assert result is None
saved = path.read_text(encoding="utf-8")
assert "Ravi" in saved and "<table" in saved
buffer = StringIO()
df.to_html(buffer, index=False)
assert buffer.getvalue() == saved
print("Saved:", path.as_posix())
print("Return value:", result)

Expected output

Saved: pandas_html_practice/students.html
Return value: None
Student DataFrame exported as an HTML table
Student table example.

Select Columns and Hide the Index

columns controls which fields appear and their order. index=False removes row labels, not an ordinary ID column. Export only fields that readers need. Selecting rows before exporting uses normal loc selection.

df = student_data()
table_html = df.to_html(index=False, columns=["NAME", "MATH"])
print(table_html)
assert "ENGLISH" not in table_html and "<th>ID</th>" not in table_html

Expected output

<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: right;">
      <th>NAME</th>
      <th>MATH</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Ravi</td>
      <td>30</td>
    </tr>
    <tr>
      <td>Raju</td>
      <td>40</td>
    </tr>
    <tr>
      <td>Alex</td>
      <td>50</td>
    </tr>
  </tbody>
</table>

header=False removes column headings. Use index=False to remove row labels. A heading-free table is harder to interpret on its own; keep headers for a standalone report.

df = student_data()
table_html = df.to_html(index=False, header=False)
print(table_html)
assert "<thead>" not in table_html

Expected output

<table border="1" class="dataframe">
  <tbody>
    <tr>
      <td>Ravi</td>
      <td>1</td>
      <td>30</td>
      <td>20</td>
    </tr>
    <tr>
      <td>Raju</td>
      <td>2</td>
      <td>40</td>
      <td>30</td>
    </tr>
    <tr>
      <td>Alex</td>
      <td>3</td>
      <td>50</td>
      <td>41</td>
    </tr>
  </tbody>
</table>

max_rows and max_cols Produce a Preview

These options truncate displayed content; they do not select an export dataset. A preview can contain ellipses. Filter records and columns explicitly when you need a complete report with fewer fields.

df = pd.DataFrame({"ID": range(1, 11), "NAME": [f"Student {n}" for n in range(1, 11)],
                   "MATH": range(10, 20), "ENGLISH": range(20, 30)})
preview = df.to_html(index=False, max_rows=5, max_cols=3, show_dimensions=True)
print(preview)
assert "..." in preview
assert "10 rows" in preview
complete_selection = df.loc[df["MATH"].ge(17), ["ID", "NAME", "MATH"]]
print("Selected records:", len(complete_selection))
assert len(complete_selection) == 3

Expected output

<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: right;">
      <th>ID</th>
      <th>...</th>
      <th>ENGLISH</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>...</td>
      <td>20</td>
    </tr>
    <tr>
      <td>2</td>
      <td>...</td>
      <td>21</td>
    </tr>
    <tr>
      <td>...</td>
      <td>...</td>
      <td>...</td>
    </tr>
    <tr>
      <td>9</td>
      <td>...</td>
      <td>28</td>
    </tr>
    <tr>
      <td>10</td>
      <td>...</td>
      <td>29</td>
    </tr>
  </tbody>
</table>
<p>10 rows × 4 columns</p>
Selected records: 3

classes, table_id and border

classes adds class names to the generated table; it does not load Bootstrap or define CSS. table_id adds an ID that you can target. border=0 omits the legacy border attribute. Use a stylesheet for presentation.

df = student_data()
table_html = df.to_html(index=False, classes=["table", "table-striped"],
                        table_id="student-table", border=0)
print(table_html.splitlines()[0])
assert 'class="dataframe table table-striped"' in table_html
assert 'id="student-table"' in table_html
assert 'border=' not in table_html

Expected output

<table class="dataframe table table-striped" id="student-table">

Column Width and Header Alignment

col_space is a minimum width, not a maximum width. A dictionary sets widths for individual columns. justify aligns column headers; use CSS to align body cells. Long text may still make a table wider, so use a scrolling wrapper.

df = student_data()
table_html = df.to_html(index=False, col_space={"NAME": "8em", "MATH": 70}, justify="center")
print("\n".join(table_html.splitlines()[:9]))
assert "min-width: 8em" in table_html
assert "min-width: 70px" in table_html
assert "text-align: center" in table_html

Expected output

<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: center;">
      <th style="min-width: 8em;">NAME</th>
      <th>ID</th>
      <th style="min-width: 70px;">MATH</th>
      <th>ENGLISH</th>
    </tr>
  </thead>

na_rep: Display Missing Values without Changing Data

na_rep controls the displayed text for missing values. It does not fill the DataFrame or turn an unknown score into zero. Use a label that communicates the meaning of missing data; see fillna() for actual replacement.

df = student_data()
df.loc[1, "MATH"] = float("nan")
table_html = df.to_html(index=False, na_rep="Not recorded")
print(table_html)
assert "Not recorded" in table_html
assert pd.isna(df.loc[1, "MATH"])

Expected output

<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: right;">
      <th>NAME</th>
      <th>ID</th>
      <th>MATH</th>
      <th>ENGLISH</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Ravi</td>
      <td>1</td>
      <td>30.0</td>
      <td>20</td>
    </tr>
    <tr>
      <td>Raju</td>
      <td>2</td>
      <td>Not recorded</td>
      <td>30</td>
    </tr>
    <tr>
      <td>Alex</td>
      <td>3</td>
      <td>50.0</td>
      <td>41</td>
    </tr>
  </tbody>
</table>

float_format, decimal and Column Formatters

Display precision does not round the stored values. float_format applies to floating values; formatters supplies functions for named columns. decimal changes the decimal separator in numeric rendering, while a custom formatter controls its own returned string.

df = pd.DataFrame({"product": ["Notebook", "Pen"], "price": [12.3456, 3.5]})
print(df.to_html(index=False, float_format=lambda value: f"{value:.2f}"))
print("Comma decimal:")
print(df.to_html(index=False, decimal=","))
currency = df.to_html(index=False, formatters={"price": lambda value: f"INR {value:,.2f}"})
assert "INR 12.35" in currency
assert df.loc[0, "price"] == 12.3456

Expected output

<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: right;">
      <th>product</th>
      <th>price</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Notebook</td>
      <td>12.35</td>
    </tr>
    <tr>
      <td>Pen</td>
      <td>3.50</td>
    </tr>
  </tbody>
</table>
Comma decimal:
<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: right;">
      <th>product</th>
      <th>price</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Notebook</td>
      <td>12,3456</td>
    </tr>
    <tr>
      <td>Pen</td>
      <td>3,5000</td>
    </tr>
  </tbody>
</table>

Show Dimensions and Control Index Labels

show_dimensions adds the original DataFrame dimensions to the output. index_names controls the displayed index name; bold_rows controls bold row labels. For a MultiIndex, sparsify=False repeats every group label instead of visually merging repeated labels.

df = student_data().set_index("NAME")
print(df.to_html(show_dimensions=True, index_names=False, bold_rows=False))
grouped = pd.DataFrame({"region": ["East", "East", "West"],
                        "product": ["Pen", "Book", "Pen"], "units": [2, 3, 4]}).set_index(["region", "product"])
table_html = grouped.to_html(sparsify=False)
print("Repeated East labels:", table_html.count(">East<"))
assert table_html.count(">East<") == 2

Expected output

<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: right;">
      <th></th>
      <th>ID</th>
      <th>MATH</th>
      <th>ENGLISH</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Ravi</td>
      <td>1</td>
      <td>30</td>
      <td>20</td>
    </tr>
    <tr>
      <td>Raju</td>
      <td>2</td>
      <td>40</td>
      <td>30</td>
    </tr>
    <tr>
      <td>Alex</td>
      <td>3</td>
      <td>50</td>
      <td>41</td>
    </tr>
  </tbody>
</table>
<p>3 rows × 3 columns</p>
Repeated East labels: 2

Keep Text Escaped in Exported Tables

escape=True is the default: angle brackets and ampersands in data are rendered as text. escape=False allows markup in values and should be reserved for content you control. Do not disable escaping simply to make a table look better.

df = pd.DataFrame({"note": ["A & B", "<strong>Priority</strong>"]})
safe_html = df.to_html(index=False, escape=True)
print(safe_html)
assert "A &amp; B" in safe_html
assert "&lt;strong&gt;Priority&lt;/strong&gt;" in safe_html
trusted_html = df.to_html(index=False, escape=False)
assert "<strong>Priority</strong>" in trusted_html

Expected output

<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: right;">
      <th>note</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>A &amp; B</td>
    </tr>
    <tr>
      <td>&lt;strong&gt;Priority&lt;/strong&gt;</td>
    </tr>
  </tbody>
</table>

render_links=True makes recognized URL values clickable. It does not turn arbitrary text into a link or validate that a destination is useful. The example uses a known tutorial URL and keeps escaping enabled.

df = pd.DataFrame({"resource": ["https://www.plus2net.com/python/pandas.php"]})
table_html = df.to_html(index=False, render_links=True)
print(table_html)
assert '<a href="https://www.plus2net.com/python/pandas.php"' in table_html

Expected output

<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: right;">
      <th>resource</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="https://www.plus2net.com/python/pandas.php" target="_blank">https://www.plus2net.com/python/pandas.php</a></td>
    </tr>
  </tbody>
</table>

CSV and Excel Sources to HTML

Read a source into a DataFrame, then use the same export options. This cell creates its own small practice files so no local upload is needed. Compare read_csv(), read_excel() and other input sources.

df = student_data()
csv_path = folder / "students.csv"
excel_path = folder / "students.xlsx"
df.to_csv(csv_path, index=False)
df.to_excel(excel_path, index=False, engine="openpyxl")
from_csv = pd.read_csv(csv_path)
from_excel = pd.read_excel(excel_path, engine="openpyxl")
assert from_csv.equals(df) and from_excel.equals(df)
from_csv.to_html(folder / "students_from_csv.html", index=False, encoding="utf-8")
from_excel.to_html(folder / "students_from_excel.html", index=False, encoding="utf-8")
print("CSV and Excel each exported", len(df), "records.")

Expected output

CSV and Excel each exported 3 records.

Filter Rows and Columns before Export

A complete selection is different from a truncated preview. Here only students with MATH at least 40 are exported. Retain original data separately so the report does not accidentally become the only copy. The larger student CSV is available for further practice.

df = student_data()
selected = df.loc[df["MATH"].ge(40), ["NAME", "MATH"]].copy()
selected.to_html(folder / "selected_students.html", index=False)
print(selected.to_string(index=False))
assert len(selected) == 2
assert len(df) == 3

Expected output

NAME  MATH
Raju    40
Alex    50

SQL Records to an HTML Table

The export step is the same for database records. This embedded SQLite example runs without a server. For MySQL connection details, see Pandas and MySQL and SQLAlchemy connections. Select required columns in the query.

import sqlite3

with sqlite3.connect(":memory:") as conn:
    student_data().to_sql("student", conn, index=False)
    df = pd.read_sql_query('SELECT "NAME", "MATH" FROM student WHERE "MATH" >= ?', conn, params=(40,))
df.to_html(folder / "students_from_sql.html", index=False)
print(df.to_string(index=False))
assert len(df) == 2

Expected output

NAME  MATH
Raju    40
Alex    50
Student records displayed in an HTML table
An HTML table can use database records as its source.

Optional MySQL Source with SQLAlchemy

This optional cell needs SQLAlchemy, PyMySQL and access to your own MySQL student table. Run it only after configuring the connection. Colab localhost refers to the Colab runtime, not your XAMPP computer. The query is read-only; the HTML file is written in the practice folder.

RUN_MYSQL = False
if RUN_MYSQL:
    import os
    from getpass import getpass
    from sqlalchemy import create_engine, URL, text

    url = URL.create("mysql+pymysql", username=os.environ["MYSQL_USER"],
                     password=getpass("MySQL password: "), host=os.environ["MYSQL_HOST"],
                     port=int(os.environ.get("MYSQL_PORT", "3306")),
                     database=os.environ["MYSQL_DATABASE"])
    engine = create_engine(url)
    try:
        with engine.connect() as conn:
            df = pd.read_sql_query(text("SELECT id, name, mark FROM student LIMIT 5"), conn)
        df.to_html(folder / "mysql_students.html", index=False, encoding="utf-8")
        print("Exported records:", len(df))
    finally:
        engine.dispose()

Desktop Clipboard and a Colab Alternative

read_clipboard() reads a desktop clipboard when a supported clipboard mechanism is available. Colab cannot access your computer clipboard directly. Paste tab-separated data into a string or upload a CSV there. The disabled desktop example shows how to select an index and drop a column.

RUN_DESKTOP_CLIPBOARD = False
if RUN_DESKTOP_CLIPBOARD:
    df = pd.read_clipboard(index_col="Value")
    df = df.drop(columns="Note")
    print(df.to_html(classes=["table", "table-striped"]))

Parse Pasted Tabular Text

Use explicit tab separators when pasting a small table. The data is embedded in the notebook, so every learner can run it without changing system clipboard settings.

pasted = chr(10).join(chr(9).join(row) for row in [["Value", "Name", "Note"], ["1", "Ravi", "Example"], ["2", "Raju", "Example"]])
df = pd.read_csv(StringIO(pasted), sep="	", index_col="Value").drop(columns="Note")
print(df.to_html(classes=["table", "table-striped"]))
assert df.index.tolist() == [1, 2]

Expected output

<table border="1" class="dataframe table table-striped">
  <thead>
    <tr style="text-align: right;">
      <th></th>
      <th>Name</th>
    </tr>
    <tr>
      <th>Value</th>
      <th></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th>1</th>
      <td>Ravi</td>
    </tr>
    <tr>
      <th>2</th>
      <td>Raju</td>
    </tr>
  </tbody>
</table>

Practical Sales Report: Select, Aggregate and Check

This synthetic order dataset contains one missing revenue value. Keep the known subtotal separate from the number of incomplete records. The groupby() report uses min_count=1 so a group with no known revenue stays missing. Reconcile the report before generating HTML.

def sales_data():
    return pd.DataFrame({"order_id": [101, 102, 103, 104, 105],
                         "category": ["Books", "Books", "Stationery", "Stationery", "Gifts"],
                         "revenue": [120.5, 80.0, 30.25, 20.0, float("nan")]})

sales = sales_data()
summary = sales.groupby("category", sort=True).agg(
    records=("order_id", "size"), known_values=("revenue", "count"),
    known_revenue=("revenue", lambda values: values.sum(min_count=1)))
summary["missing_values"] = summary["records"] - summary["known_values"]
summary = summary.reset_index()
print(summary.to_string(index=False))
assert summary["records"].sum() == len(sales)
assert summary["missing_values"].sum() == 1
assert summary["known_revenue"].sum(min_count=1) == sales["revenue"].sum(min_count=1) == 250.75

Expected output

  category  records  known_values  known_revenue  missing_values
     Books        2             2         200.50               0
     Gifts        1             0            NaN               1
Stationery        2             2          50.25               0

Build a Complete HTML Document with CSS

A table fragment needs a document title, character encoding and styles for a standalone report. CSS centers the table and controls body-cell alignment; justify alone affects headers. The scrolling wrapper helps on narrow screens. Include a caption so readers know what the table contains. Keep the missing-value label visible.

table_html = summary.to_html(index=False, table_id="sales-summary",
                             classes=["report", "center"], border=0,
                             na_rep="Not recorded", float_format=lambda value: f"{value:.2f}")
opening_end = table_html.index(">") + 1
table_html = (table_html[:opening_end] +
              "<caption>Revenue by category — synthetic practice data</caption>" +
              table_html[opening_end:])
css = """body {font-family: Arial, sans-serif; margin: 2rem; color: #222;}
.table-wrap {overflow-x: auto;}
.center {margin-left: auto; margin-right: auto;}
.report {border-collapse: collapse; min-width: 36rem;}
.report th, .report td {padding: .65rem; border: 1px solid #ccc;}
.report th {background: #eaf1fb;}
.report td {text-align: right;}
.report td:first-child {text-align: left;}
.report tbody tr:nth-child(even) {background: #f5f7fa;}
.report caption {text-align: left; font-weight: bold; margin-bottom: .75rem;}"""
document = ("<!DOCTYPE html><html lang='en'><head><meta charset='UTF-8'>"
            "<meta name='viewport' content='width=device-width, initial-scale=1'>"
            "<title>Practice Sales Report</title><style>" + css + "</style></head><body>"
            "<h1>Practice Sales Report</h1>"
            "<p>Known revenue subtotal: 250.75. One record needs review; this is not a complete revenue total.</p>"
            "<div class='table-wrap'>" + table_html + "</div></body></html>")
path = folder / "sales_report.html"
path.write_text(document, encoding="utf-8")
restored = path.read_text(encoding="utf-8")
assert "Not recorded" in restored and "250.75" in restored
assert "<caption>" in restored and "overflow-x: auto" in restored
print("Saved:", path.as_posix())
print("Known revenue subtotal:", summary["known_revenue"].sum(min_count=1))

Expected output

Saved: pandas_html_practice/sales_report.html
Known revenue subtotal: 250.75

Check the Exported HTML Structure

Check the saved file, not only the DataFrame. This small standard-library parser counts body rows, verifies that cells were not truncated and reads a known numeric value. It confirms the data structure; visual layout still needs a browser preview.

from html.parser import HTMLParser

class TableCheck(HTMLParser):
    def __init__(self):
        super().__init__()
        self.in_body = False
        self.in_cell = False
        self.rows = 0
        self.values = []
    def handle_starttag(self, tag, attrs):
        if tag == "tbody":
            self.in_body = True
        if tag == "tr" and self.in_body:
            self.rows += 1
        if tag == "td" and self.in_body:
            self.in_cell = True
    def handle_endtag(self, tag):
        if tag == "tbody":
            self.in_body = False
        if tag == "td":
            self.in_cell = False
    def handle_data(self, data):
        if self.in_cell:
            self.values.append(data.strip())

check = TableCheck()
check.feed((folder / "sales_report.html").read_text(encoding="utf-8"))
assert check.rows == len(summary) == 3
assert len(check.values) == summary.shape[0] * summary.shape[1]
assert "200.50" in check.values and "Not recorded" in check.values
print("Verified report rows:", check.rows)
print("Verified body cells:", len(check.values))

Expected output

Verified report rows: 3
Verified body cells: 15

Preview and Download in Colab

Run this cell to render the report within the notebook. Download sales_report.html from the Colab Files sidebar, then open it in a browser. Runtime files are temporary. The same file can be used in your own web project after checking its content.

try:
    from IPython.display import HTML, display
except ImportError:
    print("Open sales_report.html in a browser to preview it.")
else:

    display(HTML((folder / "sales_report.html").read_text(encoding="utf-8")))

Other Options and Common Problems

buf: no destination returns a string; a file or buffer returns None. notebook=True: changes the generated markup for notebook use, but does not display it automatically. encoding: specify UTF-8 when writing a file. Styles missing: define CSS or load the intended framework. Unexpected ellipses: remove max_rows/max_cols for a complete export. Wrong decimal text: review float_format, decimal and custom formatters. Missing values: na_rep is presentation, not replacement. More complex styling: DataFrame.style.to_html supports cell styling through the separate Styler API; do not assume its options are identical to DataFrame.to_html.

Exercise: Export Selected Student Scores

Export NAME and MATH for students with MATH at least 40, hide the index, use the table ID selected-scores, and save a UTF-8 file. Predict two data rows, then verify the exported file.

Exercise Solution

Check both selection and output. Row labels are hidden while the selected column headings stay visible.

df = student_data()
selected = df.loc[df["MATH"].ge(40), ["NAME", "MATH"]]
path = folder / "exercise_scores.html"
selected.to_html(path, index=False, table_id="selected-scores", encoding="utf-8")
result = path.read_text(encoding="utf-8")
check = TableCheck()
check.feed(result)
assert check.rows == 2
assert 'id="selected-scores"' in result
assert "Raju" in result and "Alex" in result and "Ravi" not in result
print("Verified rows:", check.rows)
print("Saved:", path.as_posix())

Expected output

Verified rows: 2
Saved: pandas_html_practice/exercise_scores.html

Check Your Understanding

Review column selection, CSS classes, index labels, missing values, minimum widths and file destinations. Try changing one option at a time and compare the generated markup.

Practice in Google Colab

Open in Google Colab View on GitHub
All synthetic sample data is included. Build and preview the report, download the HTML files and try the exercise. Save a copy in Drive before editing.

Continue with Pandas input and output, read_html(), to_excel() and to_json(). For database practice, see the student SQL sample. Reference: Pandas to_html documentation.




Subscribe to our YouTube Channel here



plus2net.com







Python Video Tutorials
Python SQLite Video Tutorials
Python MySQL Video Tutorials
Python Tkinter Video Tutorials
✖
We use cookies to improve your browsing experience. . Learn more
HTML MySQL PHP JavaScript ASP Photoshop Articles Contact us
© 2000-2026 plus2net.com All rights reserved worldwide Privacy Policy Disclaimer