Convert an Excel Sheet to a Markdown Table with Python
Markdown tables are how numbers travel outside spreadsheets: a weekly summary pasted into a pull request, a results table in a README, a status update in a wiki or a chat channel. pandas can produce one in a single call — df.to_markdown() — and for tidy data it looks right the first time. Real sheets are not always tidy, though, and a single pipe character in a customer name, a line break in a notes cell or a blank cell produces a table that renders wrongly or not at all. This page, part of Handling Excel File Formats and Conversions, converts a sheet to Markdown and fixes each of those cases.
Prerequisites
pip install pandas openpyxl tabulate
to_markdown needs the tabulate package; without it pandas raises "Missing optional dependency 'tabulate'". The examples use summary.xlsx, which has a Sales sheet with a pipe in one region name, a two-line note and a missing revenue value, and a small Stock sheet.
Step 1 — Convert with to_markdown
import pandas as pd
sales = pd.read_excel("summary.xlsx", sheet_name="Sales")
print(sales.to_markdown(index=False))
index=False drops pandas' row numbers, which almost never belong in a published table. The output is a GitHub-style "pipe" table with an alignment row: text columns are left-aligned (:---) and numbers right-aligned (---:). But look at the South row and the West row. The region "South | East" has split into two columns, the note's line break has produced a second, mostly empty line that Markdown will read as a separate row, and the missing revenue prints as nan.
Step 2 — Clean the cells before converting
All three problems are fixed in the data, before to_markdown sees it. Escape pipes, turn newlines into <br> (which GitHub, GitLab and most renderers accept inside table cells), and replace missing values with empty strings:
def markdown_safe(frame):
out = frame.copy()
text_cols = out.select_dtypes(include=["object", "string"]).columns
for col in text_cols:
out[col] = (out[col].astype("string")
.str.replace("|", r"\|", regex=False)
.str.replace("\r\n", "<br>", regex=False)
.str.replace("\n", "<br>", regex=False))
return out.astype(object).where(out.notna(), "")
print(markdown_safe(sales).to_markdown(index=False))
The table now has the right number of columns on every line and one line per row. Converting to object before filling blanks keeps numeric columns from being coerced into strings early, so number formatting in the next step still applies to the numbers that are present.
Step 3 — Format numbers and align columns
floatfmt formats float columns with a Python format specification — ",.2f" for thousands separators and two decimals. Pass a tuple for one format per column, and colalign to override the automatic alignment:
clean = markdown_safe(sales)
print(clean.to_markdown(index=False, floatfmt=("", ",.2f", "", ",.0f"),
colalign=("left", "right", "left", "right")))
Formats apply to floats only; integer columns such as units print as they are. If the table should show exactly what the spreadsheet shows — percentages as 12.5%, dates as 01 Mar 2026 — format those columns as strings first. Read Cell Values as Displayed in Excel with Python shows how to reproduce Excel's displayed text from the cell formats.
Step 4 — Convert every sheet into one document
A workbook with several sheets becomes one Markdown document with a heading per sheet. sheet_name=None reads all sheets into a dictionary:
from pathlib import Path
def workbook_to_markdown(path, title=None):
sheets = pd.read_excel(path, sheet_name=None)
parts = [f"# {title or Path(path).stem}", ""]
for name, frame in sheets.items():
parts += [f"## {name}", "", markdown_safe(frame).to_markdown(index=False), ""]
return "\n".join(parts)
md = workbook_to_markdown("summary.xlsx", title="Weekly summary")
Path("summary.md").write_text(md, encoding="utf-8")
print(md.splitlines()[:4])
print(len(md.splitlines()), "lines written")
Booleans in the Stock sheet print as True and False; map them to "yes"/"no" first if the audience expects words. For very wide sheets, pick the columns that matter — a Markdown table with twenty columns wraps badly in every renderer.
Step 5 — Convert without tabulate
On a machine where adding a dependency is not an option, a small function produces the same pipe-table format from any DataFrame. It reuses markdown_safe and right-aligns numeric columns:
def to_pipe_table(frame):
safe = markdown_safe(frame)
header = "| " + " | ".join(map(str, safe.columns)) + " |"
def is_number(col):
return pd.api.types.is_numeric_dtype(frame[col]) and not pd.api.types.is_bool_dtype(frame[col])
align = "|" + "|".join("---:" if is_number(c) else ":---" for c in frame.columns) + "|"
body = ["| " + " | ".join(str(v) for v in row) + " |" for row in safe.itertuples(index=False)]
return "\n".join([header, align, *body])
print(to_pipe_table(pd.read_excel("summary.xlsx", sheet_name="Stock")))
The is_number check excludes booleans on purpose: pandas counts a bool column as numeric, which would right-align the reorder flags. The columns are not padded to equal widths, which makes the raw text less tidy but renders identically — Markdown ignores the padding. Numbers print with Python's default formatting, so format them as strings first if the defaults are not what you want.
Keep a README table in sync with its workbook
A common use is a table in a repository's README or docs that mirrors a workbook someone maintains — a price list, a list of supported regions, a results summary. Regenerate the Markdown in a scheduled job or a CI step, and write the file only when the content actually changed, so the repository history records real changes rather than a new commit every night:
def update_if_changed(md_text, target):
target = Path(target)
old = target.read_text(encoding="utf-8") if target.exists() else None
if old == md_text:
return False
target.write_text(md_text, encoding="utf-8")
return True
print(update_if_changed(workbook_to_markdown("summary.xlsx", title="Weekly summary"), "summary.md"))
# False — identical to the file written in Step 4
Because the conversion is deterministic — same workbook in, same text out — comparing strings is enough. A CI job can go one step further and fail when the committed Markdown no longer matches the workbook, which catches the case where someone edits the spreadsheet and forgets to regenerate the docs. Put a comment such as <!-- generated from summary.xlsx, do not edit --> at the top of the file so nobody fixes a typo by hand in the Markdown and has it overwritten the next night.
Where the Markdown goes next
A generated Markdown file is easiest to use when it lands where people read. A job can commit it to a repository's docs folder, post it to a chat channel as a message, or attach it to an issue. For chat tools, keep tables small — ten or so rows — and link to the full workbook; Post an Excel Report to a Slack Channel with Python shows a job that posts a summary and the file together. For a web page instead of Markdown, Convert an Excel Sheet to an HTML Table with Python covers to_html.
Common pitfalls
- Forgetting
index=False. A column of 0, 1, 2 appears on the left. - Unescaped pipes. One
|in a cell shifts every column after it. - Raw newlines. They end the row; use
<br>. nanin the output. Fill missing values with empty strings.- Dates with a time attached. A date column read from Excel prints in full ISO form with microseconds,
2026-03-01T00:00:00.000000, which nobody wants in a table. Format it first withdf[col].dt.strftime("%Y-%m-%d")— for Markdown, text is exactly what you want. - Huge tables. Markdown renders thousands of rows slowly and nobody reads them; summarise first, and link to the full workbook for anyone who needs every row.
Performance notes
to_markdown handles tens of thousands of rows in a second or two, far more than any Markdown viewer displays comfortably. The cleaning step is vectorised string work and adds little. If a table is too large to read, the fix is a summary — a pivot or a top-N list — rather than a faster converter.
Conclusion
df.to_markdown(index=False) converts a sheet to a Markdown table in one call, once the data is made safe for it: escape | as \|, replace newlines with <br>, and fill missing values with empty strings. Format numbers with floatfmt, set alignment with colalign, convert every sheet into one document with a heading per sheet, and fall back to a twelve-line function where tabulate is not available.
Frequently asked questions
Why do I get "Missing optional dependency 'tabulate'"? DataFrame.to_markdown uses the tabulate package to lay out the table, and pandas does not install it. Run pip install tabulate, or use the small dependency-free function on this page.
Why does a pipe character in a cell break the table? Markdown uses | to separate columns, and to_markdown does not escape it. A cell containing "South | East" becomes two columns. Replace | with | in text cells before converting.
How do I keep line breaks inside a cell?
A raw newline ends the Markdown row, so the table breaks. Replace newlines with
, which GitHub, GitLab and most Markdown renderers accept inside table cells.
Can I control the number format? Yes. Pass floatfmt to to_markdown — a single format such as ",.2f" or a tuple with one format per column — or format the numbers as strings yourself before converting.
Related
- Up one level: Handling Excel File Formats and Conversions — every format Excel data moves through.
- Convert an Excel Sheet to JSON with Python — the machine-readable counterpart.
- Read All Sheets from an Excel File into DataFrames — the
sheet_name=Nonepattern. - Email an Excel Report with an HTML Summary Body — another way to put a table in front of readers.
More on file formats & conversion
- How-toConvert Excel Files to Parquet with Python
- How-toConvert Excel to CSV with Python
- ComparisonExcel vs CSV vs Parquet for Python Data Pipelines
- ComparisonPick an Excel Engine for .xlsx, .xlsm, .xls, .xlsb and .ods
- FixFix Garbled Characters Like "Café" and "’" in Excel Data
- How-toExtract VBA Macro Code from an Excel File