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.
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") == 4Expected 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>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
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_htmlExpected 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_htmlExpected 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>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) == 3Expected 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: 3classes 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_htmlExpected output
<table class="dataframe table table-striped" id="student-table">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_htmlExpected 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 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>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.3456Expected 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 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<") == 2Expected 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: 2escape=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 & B" in safe_html
assert "<strong>Priority</strong>" in safe_html
trusted_html = df.to_html(index=False, escape=False)
assert "<strong>Priority</strong>" in trusted_htmlExpected output
<table border="1" class="dataframe">
<thead>
<tr style="text-align: right;">
<th>note</th>
</tr>
</thead>
<tbody>
<tr>
<td>A & B</td>
</tr>
<tr>
<td><strong>Priority</strong></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_htmlExpected 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>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.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) == 3Expected output
NAME MATH
Raju 40
Alex 50The 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) == 2Expected output
NAME MATH
Raju 40
Alex 50
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()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"]))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>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.75Expected 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 0A 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.75Check 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: 15Run 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")))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.
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.
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.htmlReview 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.
to_html()?to_html() function?to_html()?to_html()?to_html()?to_html() to a file instead of printing it to the console?to_html()?to_html()?to_html()?to_html()?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.
Author & Instructor at plus2net
I write and maintain practical tutorials on Python, PHP, SQL, JavaScript, HTML, jQuery, and web development at plus2net. The tutorials focus on clear explanations, working examples, and code that readers can test and adapt while learning.