Convert CSV to an HTML Report with Python: Escaping, Multiline Fields, and Overwrite Protection
On this page
Your automation already produces CSV, but inspecting a small result still means opening spreadsheet software. A standalone HTML table lets a browser display the same fields, including commas and line breaks, without extra packages.
This beginner-to-intermediate tutorial parses CSV, checks headers and field counts, escapes HTML text, and exports a report. The report needs no JavaScript, external stylesheets, or network resources.
1. Method and input rules
- Input is comma-separated UTF-8 CSV, optionally with a leading BOM. The first record is the header; names cannot be blank or duplicated. Comparisons do not trim spaces.
- Every data record must have the same field count as the header. Blank physical lines also fail that check. A header-only file is valid and reports 0 records.
- All fields remain strings, so
001stays001. Escape fields, headers, and the report title as HTML text; tags in CSV are displayed as text. - The default maximum is 1000 data records. Exceeding it fails instead of producing a truncated report. This is not a byte or memory limit: the generated HTML stays in memory, so use this tool for small reports.
For required business column names or value checks, add CSV structure validation first. This renderer checks general table structure only.
2. Prepare the environment and demo data
Use an existing Python 3.10 or newer interpreter. No third-party dependencies are needed. These examples were verified on Linux with Python 3.11.4. Choose a new experiment directory:
python3 --version
mkdir csv-html-demo
cd csv-html-demo
cat > results.csv <<'CSV'
sample_id,note
001,中文结果
002,"alpha, beta"
003,"first line
second line"
004,<script>alert(1)</script>
CSV
Save the next section as csv_to_html.py, or download the complete script. The fourth record deliberately contains tag text to test escaping.
3. Complete implementation
"""Build a small standalone HTML table from UTF-8 CSV. Python 3.10+."""
import argparse
import csv
from html import escape
from pathlib import Path
import sys
def positive(value):
number = int(value)
if number < 1:
raise argparse.ArgumentTypeError("must be at least 1")
return number
def render(source, title, max_rows):
with source.open(encoding="utf-8-sig", newline="") as file:
reader = csv.reader(file, strict=True)
header = next(reader, None)
if not header or any(not name.strip() for name in header):
raise ValueError("missing or empty column name")
if len(set(header)) != len(header):
raise ValueError("duplicate column name")
parts = []
for number, row in enumerate(reader, 1):
if number > max_rows:
raise ValueError(f"more than {max_rows} data records")
if len(row) != len(header):
raise ValueError(f"record {number}: expected {len(header)} fields, got {len(row)}")
parts.append("<tr>" + "".join("<td>" + escape(cell) + "</td>" for cell in row) + "</tr>")
headings = "".join('<th scope="col">' + escape(name) + "</th>" for name in header)
safe_title = escape(title)
document = f'''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{safe_title}</title>
<style>
body {{ font: 16px system-ui, sans-serif; margin: 2rem; color: #172033; }}
.scroll {{ overflow-x: auto; }}
table {{ border-collapse: collapse; }}
th, td {{ border: 1px solid #b8c2d1; padding: .5rem; text-align: left;
white-space: pre-wrap; overflow-wrap: anywhere; max-width: 36rem; }}
th {{ background: #edf2f8; }}
</style>
</head>
<body>
<h1>{safe_title}</h1>
<p>Records: {len(parts)}</p>
<div class="scroll"><table>
<thead><tr>{headings}</tr></thead>
<tbody>{"".join(parts)}</tbody>
</table></div>
</body>
</html>
'''
return document, len(parts)
def main():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("source", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--title", default="CSV report")
parser.add_argument("--max-rows", type=positive, default=1000)
args = parser.parse_args()
try:
document, count = render(args.source, args.title, args.max_rows)
payload = document.encode("utf-8")
with args.output.open("xb") as file:
file.write(payload)
except (OSError, UnicodeError, csv.Error, ValueError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(f"OK: {count} records -> {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
The official Python CSV documentation specifies opening files with newline=""; the parser handles commas and line breaks inside quoted fields. strict=True reports some parsing errors such as malformed quotes. Header and field-count checks remain separate.
The html.escape() documentation lists the HTML special characters it converts. Dynamic values here appear only as text in titles, headings, and cells. Do not reuse this pattern directly for dynamic scripts, CSS, or URLs. The table uses white-space: pre-wrap to display embedded line breaks.
The output opens only after the entire input passes. The x mode of open() rejects an existing path, including the input file itself. This is not an atomic commit or crash-recovery mechanism: a disk-full error while writing can leave an incomplete new file. After resolving the failure, rerun with a new output name.
4. Run and view the report
Run these commands in the experiment directory:
python3 csv_to_html.py --help
python3 csv_to_html.py results.csv report.html --title 'Batch <A> & results'
python3 -m http.server 8000 --bind 127.0.0.1
The second command prints OK: 4 records -> report.html. Open http://127.0.0.1:8000/report.html in a browser on the same machine, or open the HTML file directly. Press Ctrl+C in the server terminal when finished; the server listens only on the local machine.
You should see four records, readable Chinese text, the unchanged 001, a comma inside one cell, and a note displayed on two lines. The final cell displays the literal text <script>alert(1)</script> without a popup. The report contains the source data; check that its contents are appropriate before sharing it.
5. Verify output and error behavior
Open another terminal in the same experiment directory and check the actual file:
python3 - <<'PY_CHECK'
from pathlib import Path
text = Path("report.html").read_text(encoding="utf-8")
assert "Records: 4" in text
assert "<td>001</td>" in text
assert "中文结果" in text
assert "alpha, beta" in text
assert "first line\nsecond line" in text
assert "<script>alert(1)</script>" in text
assert "<script>" not in text
assert "Batch <A> & results" in text
print("OK: records, text, and escaping")
PY_CHECK
Expect OK: records, text, and escaping. Then test overwrite protection, the record limit, and a bad field count. Run these in a regular interactive terminal to inspect each exit status:
python3 csv_to_html.py results.csv report.html
printf 'exit=%s\n' "$?"
python3 csv_to_html.py results.csv too-many.html --max-rows 3
printf 'exit=%s\n' "$?"
printf 'a,b\n1,2,3\n' > bad.csv
python3 csv_to_html.py bad.csv bad.html
printf 'exit=%s\n' "$?"
All three calls should return 1. The existing report.html remains unchanged; too-many.html and bad.html are not created. Invalid arguments such as --max-rows 0 return argparse status 2. Successful generation returns 0.
Validation also covered UTF-8 BOM, CRLF, header-only input, empty input, duplicate headers, invalid UTF-8, malformed quotes, and HTML characters in headers and titles. For large datasets, stream and aggregate the CSV into a small table before rendering it.
6. Troubleshooting
| Symptom | Action |
|---|---|
File exists |
Choose a new output name; preserve old reports that you still need. |
missing or empty column name / duplicate column name |
Correct the first CSV record so each header is nonblank and unique. |
expected ... fields |
Check that record’s quoting, delimiters, and extra blank lines; do not split on commas manually. |
more than ... data records |
Filter or aggregate first; raise --max-rows only after assessing the resulting size. |
A decoding error such as UnicodeDecodeError |
Identify and convert the encoding using the CSV encoding tutorial; do not ignore invalid bytes. |
field larger than field limit |
Reduce fields that are too long for a browser table, or explicitly assess and adjust the parser limit. |
No such file or directory / Permission denied |
Check the input path and choose an existing writable output directory. |
Summary
Use a CSV parser to preserve records and HTML text escaping to preserve their contents in a small standalone report. Check exit statuses, overwrite protection, and the generated content before adding this step after your regular batch summary.
- Author: yanzhi
- Original article: https://www.bobobk.com/en/python-csv-html-report.html
- License: This work is licensed under CC BY-NC-ND 4.0. See the license for attribution, noncommercial use and no-derivatives terms.
Related articles
- Python CSV Totals with Decimal: Validate Input and Control Rounding
- Compare CSV Snapshots by ID with Python: Added, Removed, and Changed Records
- Validate CSV Structure with Python: Exact Headers, Column Order, and Field Counts
- Fix Python CSV Field Limits: Set a Character Cap and Test Boundaries
- Split Large CSV Files with Python: Headers, Multiline Fields, and Completion Checks