Converting Excel to Markdown and HTML Tables for Docs
Turn a worksheet into a Markdown or HTML table for documentation with pandas, escape pipes and line breaks, align numbers and keep wide tables readable.

Documentation, README files and wiki pages are written in Markdown, while the numbers live in a workbook. The usual route is to copy cells and paste them, then fix the pipes by hand. That works once and rots the first time the figures change. A script that converts the sheet takes a minute to write and gives you the same table on every run. It belongs with the other conversions in the guide to converting Excel to CSV, JSON, XML and YAML, and it has its own quirks because the output is text people read.
Read the sheet as the text you want to show
Markdown has no number type. Whatever string you put in the cell is what the reader sees, so format first, convert second.
import pandas as pd
df = pd.read_excel("plans.xlsx", sheet_name="Plans", dtype={"code": "string"})
df["price"] = df["price"].map(lambda v: f"{v:,.2f}")
df["starts"] = pd.to_datetime(df["starts"]).dt.strftime("%Y-%m-%d")
df = df.fillna("")A blank cell is NaN in pandas, and NaN in a Markdown table prints as the text "nan" unless you replace it. Decide whether a gap should be an empty cell or a dash, and be consistent. Use thousands separators for money, ISO dates for dates, and leave identifiers as strings so 007 does not become 7.
Write a pipe table
print(df.to_markdown(index=False, tablefmt="pipe", colalign=("left", "left", "right", "left")))For this sample:
| code | plan | price | starts |
|---|---|---|---|
| 001 | Basic | 9.00 | 2025-01-01 |
| 002 | Team | 49.00 | 2025-01-01 |
| 003 | Business | 1,299.50 | 2025-03-14 |
the output text is:
| code | plan | price | starts |
|:-------|:---------|---------:|:-----------|
| 001 | Basic | 9.00 | 2025-01-01 |
| 002 | Team | 49.00 | 2025-01-01 |
| 003 | Business | 1,299.50 | 2025-03-14 |to_markdown calls the tabulate package, so install it first: pip install tabulate. The colalign argument sets the alignment row, which is the line of colons and dashes. Right-aligned numbers let a reader compare magnitudes at a glance, and left-aligned text reads naturally. GitHub Flavored Markdown defines this table syntax in its specification, which is worth reading once because tables are an extension to the base CommonMark.
Escape the characters that break tables
Two characters ruin a pipe table: the pipe itself and a line break.
def cell(v):
s = str(v)
return s.replace("|", "\\|").replace("\r\n", "<br>").replace("\n", "<br>")
df = df.apply(lambda col: col.map(cell))A cell with the text a|b splits into two columns unless the pipe is escaped as \|. A cell with a line break ends the row. <br> works in GitHub and most renderers, but check yours, since some strip inline HTML. If the renderer does not allow it, replace the break with a space or a semicolon. Backticks, asterisks and underscores in a cell are Markdown too: a product code such as A_1_B can turn into emphasis. Wrap codes in backticks when they carry meaning as code, so the renderer shows them literally.
Sort out the order of operations: escape before you call to_markdown, and give tabulate the strings already cleaned. Do not escape twice.
Write an HTML table instead
When the target is a web page or an email, or when the table needs merged headers, HTML is the better output.
html = df.to_html(index=False, border=0, classes="data", escape=True)
open("plans.html", "w", encoding="utf-8").write(html)escape=True is the default and should stay on: it turns <, > and & in the cell text into entities, so a cell containing <script> is displayed rather than executed. Turn it off only when the cells hold HTML that you wrote yourself. border=0 removes the legacy border attribute, and classes lets your stylesheet take over.
Pandas writes the header and body but no caption, no scope attributes and no number alignment. For an accessible table add a caption and scope attributes by hand or with a template, and set text-align: right on numeric columns in CSS. The HTML Living Standard describes the table model and what screen readers expect.
Keep the table narrow
A Markdown table has no wrapping control. Past about 100 characters of width, it scrolls sideways in a code view and becomes unreadable in a diff. Several choices keep it small:
Select only the columns the reader needs, with
df[["plan", "price", "starts"]].Shorten long text in a column, and link to the full record instead of printing it.
Round numbers to the precision that matters, such as whole currency units for a summary.
Transpose a very wide, short table so that fields become rows.
A table of 8 columns and 40 rows belongs in a file, not in the page. Write the full sheet to CSV or XLSX, link to it, and print the top five rows or a summary.
Regenerate, never hand edit
The biggest benefit of a script is that the table stays tied to the sheet. Put the conversion in the build step for your documentation:
python tools/plans_table.py > docs/_generated/plans.mdThen include the generated file from the page with whatever your site generator provides, and add a comment at the top, "generated from plans.xlsx, do not edit". A reviewer who sees a hand edit in a generated file knows it will be overwritten. If the sheet changes every week, run the script in CI so the docs update when the workbook does. For a CSV-sourced version of the same job, see converting CSV to Markdown tables for documentation.
Check the result
Open the rendered page, not the source. Look at the alignment, at a row with an accent or a currency symbol, at a cell that had a pipe, and at the first and last rows. Count the rows against the sheet. If the table feeds a published page, run the page through your site's link and build checks. A broken table usually shows up as a stray row of dashes or a paragraph of run-together text.
The same approach works for the other text formats described in converting JSON to a Markdown table, where the cells come from nested data.
The small CSV to Markdown converter does the same job for a pasted sample and runs in your browser, so it is safe for non-sensitive tables.
When a pasted table is the wrong answer
A table is the wrong answer when the point is a trend, because a chart communicates a trend faster than 40 numbers do. It is wrong when the reader will copy the figures into their own work; give them a CSV. It is wrong when the data is confidential, since documentation tends to be broader reading than a finance workbook. And it is wrong when the numbers change daily: link to a live report, not a snapshot that will mislead the next reader. If you do paste, say which workbook and which date the figures came from, as part of the surrounding sentence.


