pandas DataFrame to HTML: send a report someone can read

Your calculation is finished, but the person checking it does not need your notebook. They need the table, what its numbers mean, and anything still missing.

Start with DataFrame.to_html() for a plain table or Styler.to_html() when the formatting carries meaning. Then put that output in a complete report with a title and a short explanation.

Pick the export that matches what you need to preserve

DataFrame.to_html() produces an HTML table. Styler.to_html() includes the table's style rules; its doctype_html=True option can produce a complete document. The methods do not turn a Python calculation into a live Python application. Plain table export · Styled export

For a quick plain export:

from pathlib import Path

table = df.to_html(index=False, escape=True, na_rep="Pending")
Path("table.html").write_text(table, encoding="utf-8")

That is a table file. For a report you can hand over, add the context shown in the worked example below. Keep the calculation in Python and the explanation next to its result.

Keep the missing line visible

Our original fictional example is a small project estimate. Discovery is 6 hours at $95, the prototype is 12 hours at $95, and QA is 4 hours at $85. Handoff hours have not been agreed.

Those three known amounts are $570, $1,140, and $340. Their subtotal is $2,050. That is not the final project price: the handoff row is still pending. Writing zero into the missing cell would make the table look more settled than the supplied data allows.

The accompanying Python source builds that DataFrame, calculates the amounts, and exports the actual styled table into a complete HTML report. The report keeps “Pending” in the handoff row and labels $2,050 as the known subtotal. Its displayed values come from the Python result, rather than a separately typed illustration.

A real pandas export of a fictional estimate: $2,050 known subtotal; handoff pending.
A real pandas export of a fictional estimate: $2,050 known subtotal; handoff pending.
Open the full example ↗

Open the interactive example · Open the standalone HTML

Here is the formatting part:

styled = (
    df.style.hide(axis="index")
    .format(
        {"Hours": "{:,.0f}", "Rate": "${:,.0f}", "Amount": "${:,.0f}"},
        na_rep="Pending",
        escape="html",
    )
    .format_index(axis="columns", escape="html")
    .set_caption("Illustrative estimate — handoff hours pending")
)
table = styled.to_html()

The complete source wraps that table in the report's title, styles, viewport setting, and explanation. You can change the presentation without silently changing which amounts are known. If values have landed against the wrong records before export, fix that earlier problem first; the pandas alignment guide addresses it.

Run the complete Python example

Install pandas with its styling dependencies in your Python environment, then save the source below as pandas-report.py. Run python pandas-report.py; it writes the report and CSV beside the script. This example was executed with Python 3.10.6 and pandas 2.3.1.

python -m pip install "pandas[output_formatting]"
python pandas-report.py

Get the complete Python source · Inspect the example CSV

Show the complete styled Python source
"""Build the article's original fictional estimate with real pandas output."""
from html import escape
import json
from pathlib import Path
import sys

import pandas as pd

ROOT = Path(__file__).resolve().parent


def build_report() -> dict:
    df = pd.DataFrame({
        "Work": ["Discovery", "Prototype review", "QA", "Handoff"],
        "Hours": [6.0, 12.0, 4.0, None],
        "Rate": [95.0, 95.0, 85.0, 85.0],
    })
    df["Amount"] = df["Hours"] * df["Rate"]
    known_subtotal = df["Amount"].sum(min_count=1)
    assert df.loc[0, "Amount"] == 570
    assert df.loc[1, "Amount"] == 1140
    assert df.loc[2, "Amount"] == 340
    assert pd.isna(df.loc[3, "Amount"])
    assert known_subtotal == 2050

    styled = (
        df.style.hide(axis="index")
        .format(
            {"Hours": "{:,.0f}", "Rate": "${:,.0f}", "Amount": "${:,.0f}"},
            na_rep="Pending", escape="html",
        )
        .format_index(axis="columns", escape="html")
        .set_caption("Illustrative estimate — handoff hours pending")
        .set_uuid("project_estimate")
        .set_properties(**{"text-align": "right"}, subset=["Hours", "Rate", "Amount"])
        .set_properties(**{"text-align": "left"}, subset=["Work"])
    )
    table = styled.to_html()
    escape_probe = pd.DataFrame({"Text": ["<review>"]}).style.format(escape="html").to_html()
    assert "&lt;review&gt;" in escape_probe
    assert "<review>" not in escape_probe
    assert table.count('class="data row') == 16
    title = "Website prototype estimate"
    html = f'''<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{escape(title)}</title><style>
*{{box-sizing:border-box}}body{{margin:0;background:#eeeae2;color:#252620;font:16px/1.55 system-ui,-apple-system,Segoe UI,sans-serif}}main{{max-width:920px;margin:38px auto;padding:32px clamp(16px,5vw,46px);background:#fffdf7;border-top:6px solid #9b481e}}.eyebrow{{color:#725b46;margin:0;font-weight:650}}h1{{font:500 clamp(31px,5vw,47px)/1.15 Georgia,serif;margin:16px 0;max-width:660px}}.lede{{max-width:650px;margin:0 0 26px;color:#55564d}}table{{width:100%;border-collapse:collapse;table-layout:fixed;font-size:16px}}caption{{text-align:left;font-weight:650;padding:0 0 13px}}th,td{{padding:14px 6px;border-bottom:1px solid #dedbd2;overflow-wrap:anywhere;vertical-align:top;font-variant-numeric:tabular-nums}}thead th{{background:#f0ede4;text-align:right}}thead th:first-child{{text-align:left}}th:first-child,td:first-child{{width:34%}}th:nth-child(2),td:nth-child(2){{width:20%}}th:nth-child(3),td:nth-child(3){{width:18%}}th:last-child,td:last-child{{width:28%}}tbody tr:last-child{{background:#fff2d7}}.subtotal{{display:flex;justify-content:space-between;align-items:baseline;gap:12px;margin:21px 0 8px;font-weight:650}}.subtotal strong{{font:500 clamp(31px,6vw,45px)/1.2 Georgia,serif}}.note{{padding:15px 17px;border-left:3px solid #9b481e;background:#f5eee3;margin:17px 0 23px}}.note p{{margin:0}}h2{{font-size:18px;margin:0 0 8px}}footer{{border-top:1px solid #d4cec0;padding-top:17px;margin-top:26px;color:#646353}}@media(max-width:600px){{main{{margin:0;padding:23px 16px 30px}}th,td{{padding:12px 4px}}h1{{font-size:33px}}.subtotal{{align-items:flex-start}}}}@media print{{body{{background:white}}main{{margin:0;border-top:3px solid #9b481e}}}}
</style></head><body><main><p class="eyebrow">PROJECT ESTIMATE / REVIEW COPY</p><h1>{escape(title)}</h1><p class="lede">Original fictional example. The known work is priced below; handoff hours still need agreement.</p>
{table}
<div class="subtotal"><span>Known subtotal</span><strong>${known_subtotal:,.0f}</strong></div>
<div class="note"><p><strong>This is not the final price.</strong> Handoff hours and their amount remain pending. A missing amount has not been replaced with zero.</p></div>
<h2>What to confirm</h2><p>Check the three priced work items and agree the handoff hours before a final estimate is issued.</p>
<footer>Generated from the accompanying Python source · pandas {escape(pd.__version__)} · static report, no live updates.</footer></main></body></html>'''
    target = ROOT / "pandas-dataframe-to-html-report.html"
    target.write_text(html, encoding="utf-8")
    df.to_csv(ROOT / "pandas-report-source.csv", index=False)
    result = {
        "pandasVersion": pd.__version__, "pythonVersion": sys.version.split()[0],
        "fictional": True, "rows": len(df), "columns": len(df.columns),
        "knownSubtotal": float(known_subtotal), "pendingAmountRows": int(df["Amount"].isna().sum()),
        "numericAssertionsPassed": True, "escapingAssertionPassed": True,
        "tableDataCells": 16, "html": str(target),
    }
    (ROOT / "pandas-execution.json").write_text(
        json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8",
    )
    return result


if __name__ == "__main__":
    print(json.dumps(build_report(), ensure_ascii=False))

Check the output, not just the successful script

Open the generated HTML and compare each row with the DataFrame. In this example, check the three amounts, the $2,050 known subtotal, and the pending handoff amount. Also check the page at phone width: a clipped amount or an unreadable column header can undo the work of formatting the table.

For your own report, include a recognizable record, a missing value if one exists, and the total's inclusion rule in that comparison. Check the number of exported rows too. Pandas exposes rendering limits, so an export command finishing is not evidence that every intended row appears. Styler rendering limits

If source text can contain <, >, or &, keep HTML escaping enabled. Treat text as text rather than disabling escaping to make a cell look right. Escaping in DataFrame output

Use AI to adapt the report without inventing the missing data

Adapt this pandas-to-HTML report for [AUDIENCE AND PURPOSE].
Columns, units, and display formats: [LIST].
Permitted sample or DataFrame construction: [PASTE].
Missing-value policy: [ENTER]. Total inclusion rule: [ENTER].
Required delivery format: [ENTER].

Keep calculation in Python. Preserve record identity, source values, and
missing values. Do not invent amounts or call a partial total final.
Export a complete HTML document with local CSS, a viewport meta tag,
readable column labels, and a note explaining the total. Escape source
text. No external fonts, images, scripts, or live-data claims.
Include the Python source and explicit expected checks. Separate those
expectations from any results you have actually executed.

Hand over the reviewed result

Keep the CSV or notebook as the calculation source. If the recipient needs to filter, recalculate, or audit that source, send the appropriate data or workbook as well. The HTML report is the readable result, not a replacement for every underlying file.

Once the report is correct, sharing an HTML report through NOS is a separate delivery option. For this estimate, ask the reviewer to confirm the known work and supply the handoff hours. Resolve that missing input in Python before issuing a final amount.

For a Quarto source document, bundle its report as one HTML file before sending it without a companion folder.

For a chart the recipient needs to filter, prepare a separate interactive chart and verify its values in the delivered file.

Keep reading