How-to
Getting Started With Python Excel AutomationDeep dive

Convert an Excel Sheet to a Markdown Table with Python

Turn a worksheet into a Markdown table for a README, a wiki page, a pull request or a chat message. Use DataFrame.to_markdown, then fix what it gets wrong — unescaped pipes, line breaks inside cells, "nan" for blanks and raw float formatting — and convert every sheet of a workbook into one document.

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.

A worksheet and the Markdown table made from it Left: a Sales sheet with region, revenue and units columns. Right: the same data as Markdown text, with a header row, an alignment row of dashes and colons, and one line per data row separated by pipe characters. A B C 1 region revenue units 2 North 1200.5 12 3 West 9 A 1 | region | revenue | units | 2 |:-------|--------:|------:| 3 | North | 1,200.50| 12 | 4 | West | | 9 | summary.xlsx — Sales Markdown text to_markdown()

Prerequisites

Bash
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

Python
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:

Python
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.

Cell contents that break Markdown tables A table of problem cell contents. A pipe character splits the cell into two columns; replace it with backslash-pipe. A newline ends the row early; replace it with <br>. A missing value prints as nan; replace it with an empty string. Unformatted floats show raw precision; pass floatfmt. What to_markdown does Fix before converting Pipe "South | East" splits into two columns replace | with \| Newline in a note ends the row early replace with <br> Missing value prints "nan" fill with "" Float 1200.5 raw precision floatfmt=",.2f"

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:

Python
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:

Python
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:

Python
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.

From workbook to publishable Markdown Four steps. Read every sheet with sheet_name=None. Make each DataFrame Markdown-safe by escaping pipes, converting newlines to <br> and blanking missing values. Convert with to_markdown and number formats, or with the dependency-free function. Write one document with a heading per sheet. Read all sheets sheet_name=None Make cells safe pipes, newlines, blanks Convert to_markdown with floatfmt Write one document ## heading per sheet

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:

Python
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>.
  • nan in 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 with df[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.

More on file formats & conversion

More on file formats & conversion →