Markdown Tables in Python: pandas to_markdown, tabulate and Parsing
By MarkdownTables.com editorial teamUpdated
On this page
How do I convert a pandas DataFrame to a Markdown table?
DataFrame.to_markdown() has been in pandas since 1.0. It hands the data to the tabulate package, which pandas lists as an optional dependency, so install both. The code below was checked with pandas 2.2 and 3.0 and tabulate 0.10.
# pip install pandas tabulate
import pandas as pd
df = pd.DataFrame({
"model": ["baseline", "small", "large"],
"params_m": [0.0, 124.0, 1558.0],
"accuracy": [0.71234, 0.83891, 0.91207],
"latency_ms": [3.2, 18.75, 142.5],
})
print(df.to_markdown(index=False))index=False| model | params_m | accuracy | latency_ms |
|:---------|-----------:|-----------:|-------------:|
| baseline | 0 | 0.71234 | 3.2 |
| small | 124 | 0.83891 | 18.75 |
| large | 1558 | 0.91207 | 142.5 || model | params_m | accuracy | latency_ms |
|---|---|---|---|
| baseline | 0 | 0.71234 | 3.2 |
| small | 124 | 0.83891 | 18.75 |
| large | 1558 | 0.91207 | 142.5 |
The default tablefmt is pipe: text columns get :--- and numeric columns ---:, so numbers line up on GitHub, GitLab and in VS Code.
Leave out index=False and the RangeIndex becomes an unlabeled first column, which is rarely what you want in a README:
| | model | params_m | accuracy | latency_ms |
|---:|:---------|-----------:|-----------:|-------------:|
| 0 | baseline | 0 | 0.71234 | 3.2 |
| 1 | small | 124 | 0.83891 | 18.75 |
| 2 | large | 1558 | 0.91207 | 142.5 || model | params_m | accuracy | latency_ms | |
|---|---|---|---|---|
| 0 | baseline | 0 | 0.71234 | 3.2 |
| 1 | small | 124 | 0.83891 | 18.75 |
| 2 | large | 1558 | 0.91207 | 142.5 |
The output follows the Markdown table syntax GitHub uses, so you can paste it into a README, an issue, a GitLab merge request or a docs page as is.
Python methods compared
| Method | Install | Best for | Notes |
|---|---|---|---|
df.to_markdown() | pandas, tabulate | DataFrames, reports, notebooks | Alignment colons by dtype; floatfmt, colalign |
tabulate(rows, tablefmt="pipe") | tabulate | Lists, dicts, JSON without pandas | Same formatter pandas uses |
pl.Config(tbl_formatting="MARKDOWN") | polars | Printing polars frames | No alignment colons; display options only |
| Hand-written function | None | Scripts with no dependencies, CI jobs | You handle escaping and alignment |
pd.read_csv(sep="|") | pandas | Reading a simple table back | Fails on escaped \| pipes |
| Regex parser | re (stdlib) | Reading any GFM pipe table | Every cell comes back as text |
to_markdown options: index, alignment, table format
to_markdown(buf=None, *, mode="wt", index=True, storage_options=None, **kwargs) passes every extra keyword argument to tabulate(). These are the ones that matter for Markdown:
| Argument | Effect | Example |
|---|---|---|
index | Include the index as the first column (default True) | index=False |
buf | Write to a path or file object instead of returning a string | df.to_markdown("out.md") |
floatfmt | Number format for floats; a string or one entry per column | ".2f", (".0f", ".1%") |
colalign | Alignment per column: "left", "center", "right" | ("left", "right") |
tablefmt | Output format; default "pipe" | "github", "grid" |
disable_numparse | Treat every cell as text (no number alignment or formatting) | disable_numparse=True |
maxcolwidths | Wrap long text; wrapped lines become extra rows, so avoid it for Markdown | — |
# Write straight to a file (buf accepts a path or a file object)
df.to_markdown("results.md", index=False)
# Per-column alignment: one entry per column (index=False means no index entry)
df.to_markdown(index=False, colalign=("left", "right", "right", "right"))
# Keep a meaningful index, e.g. after groupby
df.set_index("model").to_markdown()
# Other tabulate formats
df.to_markdown(index=False, tablefmt="github") # pipe table without alignment colons
df.to_markdown(index=False, tablefmt="grid") # Pandoc/reStructuredText grid tabletablefmt="github" produces a pipe table with plain |---| delimiters, so numbers are no longer right-aligned. Stick with the default pipe format unless a linter requires otherwise. For Pandoc grid tables, see Pandoc table formats.
Float format and scientific notation
tabulate formats floats with "g" by default. That trims trailing zeros, but it also switches to scientific notation for very small or large numbers and keeps only six significant digits, so 1234567.0 becomes 1.23457e+06, which is a different number.
s = pd.DataFrame({"value": [0.000012, 1234567.0, 3.5]})
print(s.to_markdown(index=False)) # default "g": 1.2e-05, 1.23457e+06
print(s.to_markdown(index=False, floatfmt=",.2f")) # 0.00, 1,234,567.00, 3.50
print(s.to_markdown(index=False, floatfmt=".6f")) # 0.000012, 1234567.000000, 3.500000floatfmt: scientific notation, digits lost| value |
|------------:|
| 1.2e-05 |
| 1.23457e+06 |
| 3.5 || value |
|---|
| 1.2e-05 |
| 1.23457e+06 |
| 3.5 |
floatfmt=",.2f": fixed decimals and thousands separators| value |
|-------------:|
| 0.00 |
| 1,234,567.00 |
| 3.50 || value |
|---|
| 0.00 |
| 1,234,567.00 |
| 3.50 |
Any Python format spec works: ".2f", ",.0f", ".1%", ".3e". Pass a tuple to format each column differently; text columns ignore their entry:
df.to_markdown(index=False, floatfmt=".2f")
# One format per column (strings ignore it); "" keeps the default
df.to_markdown(
index=False,
floatfmt=("", ".0f", ".1%", ".1f"),
)| model | params_m | accuracy | latency_ms |
|:---------|-----------:|-----------:|-------------:|
| baseline | 0 | 71.2% | 3.2 |
| small | 124 | 83.9% | 18.8 |
| large | 1558 | 91.2% | 142.5 || model | params_m | accuracy | latency_ms |
|---|---|---|---|
| baseline | 0 | 71.2% | 3.2 |
| small | 124 | 83.9% | 18.8 |
| large | 1558 | 91.2% | 142.5 |
Settings such as pd.options.display.float_format or df.style.format() have no effect here, because tabulate does the formatting. If you need different formats inside one column, format the values to strings yourself and pass disable_numparse=True.
Pipes, newlines and missing values in cells
tabulate doesn't know about Markdown, so it doesn't escape anything. A | in a value adds a column, and a newline splits the row in two. Clean text columns before converting:
| cmd | note |
|:------|:-------|
| a | b | line1 |
| | line2 || cmd | note | Column 3 |
|---|---|---|
| a | b | line1 |
| line2 |
markdown_safe()| cmd | note |
|:-------|:---------------|
| a \| b | line1<br>line2 || cmd | note |
|---|---|
| a | b | line1 line2 |
import pandas as pd
def markdown_safe(df: pd.DataFrame) -> pd.DataFrame:
"""Escape pipes and replace newlines in text columns so each row stays one table row."""
out = df.copy()
for col in out.select_dtypes(include=["object", "string"]).columns:
out[col] = (
out[col]
.fillna("")
.astype(str)
.str.replace("|", r"\|", regex=False)
.str.replace("\n", "<br>", regex=False)
)
return out
print(markdown_safe(df).to_markdown(index=False))Missing values print as nan. tabulate's missingval option only replaces None, so convert first:
# tabulate's missingval only replaces None, and pandas passes NaN through as "nan".
clean = df.astype(object).where(df.notna(), None)
print(clean.to_markdown(index=False, missingval="n/a", floatfmt=".2f"))More on why the backslash is needed (and why it is needed inside backticks too) in escaping pipes.
polars DataFrames to Markdown
polars has no to_markdown() method, but its display config can print a Markdown table. The setting changes how the frame is printed, so capture str(df) inside the pl.Config block:
import polars as pl
df = pl.DataFrame({
"model": ["baseline", "small", "large"],
"accuracy": [0.71234, 0.83891, 0.91207],
})
with pl.Config(
tbl_formatting="MARKDOWN",
tbl_hide_column_data_types=True,
tbl_hide_dataframe_shape=True,
tbl_rows=-1, # don't truncate rows
tbl_cols=-1, # don't truncate columns
fmt_str_lengths=1000, # don't shorten long strings
float_precision=2,
):
md = str(df)
print(md)
# Or convert and use pandas/tabulate for colalign and floatfmt
print(df.to_pandas().to_markdown(index=False))| model | accuracy |
|----------|----------|
| baseline | 0.71 |
| small | 0.84 |
| large | 0.91 || model | accuracy |
|---|---|
| baseline | 0.71 |
| small | 0.84 |
| large | 0.91 |
polars writes |---| delimiters without alignment colons, so numbers are left-aligned when rendered. Use to_pandas() (needs pandas installed) for right-aligned numbers and floatfmt.
tabulate without pandas, and JSON to Markdown
Without pandas, call tabulate directly. Use tablefmt="pipe"; the default format is plain text, not Markdown.
# pip install tabulate
from tabulate import tabulate
rows = [["Ada", 92.5], ["Grace", 88]]
print(tabulate(rows, headers=["Name", "Score"], tablefmt="pipe", floatfmt=".1f"))JSON to Markdown. A list of objects maps straight to rows. headers="keys" collects every key, so objects with different fields still line up:
import json
from tabulate import tabulate
with open("users.json") as f:
records = json.load(f) # a list of objects
# headers="keys" uses the union of all keys; missing values become empty cells
print(tabulate(records, headers="keys", tablefmt="pipe"))Nested objects need flattening first; pd.json_normalize turns them into dotted column names:
import pandas as pd
nested = [{"id": 1, "user": {"name": "Ada", "email": "ada@example.com"}, "tags": ["ml", "infra"]}]
flat = pd.json_normalize(nested) # user.name, user.email columns
flat["tags"] = flat["tags"].str.join(", ") # lists -> text
print(flat.to_markdown(index=False))| id | tags | user.name | user.email |
|-----:|:----------|:------------|:----------------|
| 1 | ml, infra | Ada | ada@example.com || id | tags | user.name | user.email |
|---|---|---|---|
| 1 | ml, infra | Ada | ada@example.com |
For a script with no dependencies at all, such as a CI step, a short function is enough. It doesn't align numbers, but the output renders the same:
def to_markdown(rows: list[dict]) -> str:
"""Minimal Markdown table from a list of dicts, no dependencies."""
headers = list(dict.fromkeys(k for row in rows for k in row))
def cell(value) -> str:
text = "" if value is None else str(value)
return text.replace("|", r"\|").replace("\n", "<br>")
lines = [
"| " + " | ".join(headers) + " |",
"| " + " | ".join("---" for _ in headers) + " |",
]
lines += ["| " + " | ".join(cell(row.get(h)) for h in headers) + " |" for row in rows]
return "\n".join(lines)For one-off conversions, paste data into the JSON to Markdown or CSV to Markdown converter instead of writing code.
Show a DataFrame as a Markdown table in Jupyter
Jupyter already renders DataFrames as HTML. To see the Markdown version rendered (for example to check it before pasting it into docs), wrap the string in IPython.display.Markdown:
from IPython.display import Markdown, display
display(Markdown(df.to_markdown(index=False, floatfmt=".2f")))In a Markdown cell, a hand-written pipe table renders directly; Jupyter's Markdown supports GFM tables. Use print(df.to_markdown()) when you want the raw source to copy.
How do I parse a Markdown table in Python?
For a well-formed table without escaped pipes, read_csv with | as the separator does the job and infers numeric types. Two details: skiprows=[1] drops the delimiter row before type inference, and .iloc[:, 1:-1] drops the empty columns that the leading and trailing pipes create.
| Name | Team | Score | Joined |
| :----- | :------- | -----: | :--------- |
| Ada | Platform | 92.5 | 2024-03-01 |
| Grace | Compiler | 88 | 2023-11-15 |
| Hedy | Radio | 100 | 2025-01-20 || Name | Team | Score | Joined |
|---|---|---|---|
| Ada | Platform | 92.5 | 2024-03-01 |
| Grace | Compiler | 88 | 2023-11-15 |
| Hedy | Radio | 100 | 2025-01-20 |
import io
import pandas as pd
md = """
| Name | Team | Score | Joined |
| :----- | :------- | -----: | :--------- |
| Ada | Platform | 92.5 | 2024-03-01 |
| Grace | Compiler | 88 | 2023-11-15 |
| Hedy | Radio | 100 | 2025-01-20 |
"""
df = pd.read_csv(
io.StringIO(md.strip()),
sep="|",
skipinitialspace=True,
skiprows=[1], # the | --- | delimiter row
).iloc[:, 1:-1] # empty columns created by the outer pipes
df.columns = df.columns.str.strip()
for col in df.select_dtypes(include=["object", "string"]).columns:
df[col] = df[col].str.strip()
print(df.dtypes) # Score is float64; Name, Team, Joined are text
print(df["Score"].mean()) # 93.5The read_csv approach splits on every pipe, including escaped \| ones, and assumes outer pipes. For tables from READMEs or other people's docs, this parser follows the GFM rules: it splits only on unescaped pipes, accepts rows without outer pipes, pads short rows and drops extra cells.
import re
import pandas as pd
def parse_markdown_table(md: str) -> pd.DataFrame:
"""Parse the first pipe table in md. Handles escaped pipes and missing outer pipes."""
rows = []
for line in md.strip().splitlines():
line = line.strip()
if "|" not in line:
if rows:
break # the first non-table line after the table ends it
continue
if line.startswith("|"):
line = line[1:]
if line.endswith("|") and not line.endswith("\\|"):
line = line[:-1]
cells = [c.strip().replace("\\|", "|") for c in re.split(r"(?<!\\)\|", line)]
rows.append(cells)
header, delimiter, *body = rows
if not all(re.fullmatch(r":?-+:?", c) for c in delimiter):
raise ValueError("second line is not a | --- | delimiter row")
width = len(header)
body = [(r + [""] * width)[:width] for r in body] # pad short rows, drop extra cells
return pd.DataFrame(body, columns=header)
df = parse_markdown_table(open("README.md").read())
df["Score"] = pd.to_numeric(df["Score"]) # every cell is text; convert what you needTo check a table before parsing, paste it into the table validator. To go the other way without code, Markdown table to CSV exports a file pandas reads directly.
Render a Markdown table to HTML in Python
To turn Markdown containing tables into HTML (for an email report or a static page), use a renderer with the table extension switched on. Both of these follow the pipe-table syntax and keep alignment as style attributes:
# pip install markdown
import markdown
html = markdown.markdown(md, extensions=["tables"])
# pip install markdown-it-py
from markdown_it import MarkdownIt
html = MarkdownIt("commonmark").enable("table").render(md)Python-Markdown needs extensions=["tables"]; without it the pipes stay as text. In markdown-it-py, the "commonmark" preset has tables off until you call .enable("table"). Rendering in a React app instead? See Markdown tables in React.
Common errors and fixes
| Symptom | Cause | Fix |
|---|---|---|
ImportError: Missing optional dependency 'tabulate' | tabulate isn’t installed in this environment | pip install tabulate; restart the kernel |
| Extra 0, 1, 2 column | Index included by default | index=False |
1.23457e+06 in a cell | Default "g" float format | floatfmt=",.2f" |
| Row split into two lines | Newline inside a value | Replace \n with <br> first |
| Extra column in one row | Unescaped | in a value | Replace | with \| first |
nan in cells | Missing values are floats | Convert NaN to None, then missingval="n/a" |
| Numbers left-aligned | tablefmt="github" or polars output | Use the default pipe format, or colalign |
| Zip codes or IDs right-aligned | tabulate treats numeric-looking text as numbers | disable_numparse=True or colalign |
Frequently asked questions
How do I use pandas to_markdown without the index?
Pass index=False: df.to_markdown(index=False). Without it, pandas adds the index as an unlabeled first column (0, 1, 2…). If the index holds real labels, keep it or move it into a column with df.reset_index().
Why does df.to_markdown() raise ImportError: Missing optional dependency tabulate?
to_markdown uses the tabulate package, which pandas does not install. Run pip install tabulate (or conda install tabulate) in the same environment, then restart the Jupyter kernel if you are in a notebook.
How do I set the float format in pandas to_markdown?
Pass floatfmt, which goes straight to tabulate: df.to_markdown(index=False, floatfmt=".2f"). Use a tuple for one format per column, ",.2f" for thousands separators, or ".1%" for percentages. The default "g" switches to scientific notation and keeps only six significant digits.
How do I stop scientific notation in a Markdown table from pandas?
Set a fixed-point format: floatfmt=".2f" (or ".6f" for small numbers). pandas display options such as pd.options.display.float_format do not affect to_markdown, because tabulate does the formatting.
How do I parse a Markdown table into a pandas DataFrame?
Read it with pd.read_csv(io.StringIO(md), sep="|", skipinitialspace=True, skiprows=[1]), drop the empty first and last columns with .iloc[:, 1:-1], and strip whitespace from headers and text cells. For tables with escaped \| pipes, use a small regex parser instead.
How do I convert JSON to a Markdown table in Python?
Load it with json.load, then call tabulate(records, headers="keys", tablefmt="pipe") for a list of objects. For nested JSON, flatten it first with pd.json_normalize(records) and call .to_markdown(index=False). Or paste it into the JSON to Markdown converter.
Can polars output a Markdown table?
Yes, for printing: pl.Config(tbl_formatting="MARKDOWN") makes print(df) and str(df) produce a pipe table. Hide the dtype row and shape with tbl_hide_column_data_types=True and tbl_hide_dataframe_shape=True, or convert with df.to_pandas().to_markdown().
Related: Pandoc table formats, Markdown tables in React and column alignment.