Skip to content

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.

DataFrame to MarkdownPython
# 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))
Output with index=False
Markdown
| model    |   params_m |   accuracy |   latency_ms |
|:---------|-----------:|-----------:|-------------:|
| baseline |          0 |    0.71234 |         3.2  |
| small    |        124 |    0.83891 |        18.75 |
| large    |       1558 |    0.91207 |       142.5  |
Preview
modelparams_maccuracylatency_ms
baseline00.712343.2
small1240.8389118.75
large15580.91207142.5

The default tablefmt is pipe: text columns get :--- and numeric columns ---:, so numbers line up on GitHub, GitLab and in VS Code.

Open in editor : Output with index=False

Leave out index=False and the RangeIndex becomes an unlabeled first column, which is rarely what you want in a README:

Default output: the index is included
Markdown
|    | 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  |
Preview
modelparams_maccuracylatency_ms
0baseline00.712343.2
1small1240.8389118.75
2large15580.91207142.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

Ways to produce or read Markdown tables in Python
MethodInstallBest forNotes
df.to_markdown()pandas, tabulateDataFrames, reports, notebooksAlignment colons by dtype; floatfmt, colalign
tabulate(rows, tablefmt="pipe")tabulateLists, dicts, JSON without pandasSame formatter pandas uses
pl.Config(tbl_formatting="MARKDOWN")polarsPrinting polars framesNo alignment colons; display options only
Hand-written functionNoneScripts with no dependencies, CI jobsYou handle escaping and alignment
pd.read_csv(sep="|")pandasReading a simple table backFails on escaped \| pipes
Regex parserre (stdlib)Reading any GFM pipe tableEvery 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:

Useful to_markdown arguments
ArgumentEffectExample
indexInclude the index as the first column (default True)index=False
bufWrite to a path or file object instead of returning a stringdf.to_markdown("out.md")
floatfmtNumber format for floats; a string or one entry per column".2f", (".0f", ".1%")
colalignAlignment per column: "left", "center", "right"("left", "right")
tablefmtOutput format; default "pipe""github", "grid"
disable_numparseTreat every cell as text (no number alignment or formatting)disable_numparse=True
maxcolwidthsWrap long text; wrapped lines become extra rows, so avoid it for Markdown—
Common variationsPython
# 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 table

tablefmt="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.

Scientific notation and how to avoid itPython
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.500000
Default floatfmt: scientific notation, digits lost
Markdown
|       value |
|------------:|
| 1.2e-05     |
| 1.23457e+06 |
| 3.5         |
Preview
value
1.2e-05
1.23457e+06
3.5
floatfmt=",.2f": fixed decimals and thousands separators
Markdown
|        value |
|-------------:|
|         0.00 |
| 1,234,567.00 |
|         3.50 |
Preview
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:

One float format per columnPython
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"),
)
Per-column formats: integer, percent, one decimal
Markdown
| model    |   params_m |   accuracy |   latency_ms |
|:---------|-----------:|-----------:|-------------:|
| baseline |          0 |      71.2% |          3.2 |
| small    |        124 |      83.9% |         18.8 |
| large    |       1558 |      91.2% |        142.5 |
Preview
modelparams_maccuracylatency_ms
baseline071.2%3.2
small12483.9%18.8
large155891.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:

Raw output: broken row
Markdown
| cmd   | note   |
|:------|:-------|
| a | b | line1  |
|       | line2  |
Preview
cmdnoteColumn 3
abline1
line2
After markdown_safe()
Markdown
| cmd    | note           |
|:-------|:---------------|
| a \| b | line1<br>line2 |
Preview
cmdnote
a | bline1
line2
Escape pipes and newlinesPython
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:

Show NaN as n/aPython
# 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:

polars Markdown outputPython
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))
polars MARKDOWN output
Markdown
| model    | accuracy |
|----------|----------|
| baseline | 0.71     |
| small    | 0.84     |
| large    | 0.91     |
Preview
modelaccuracy
baseline0.71
small0.84
large0.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.

Open in editor : polars MARKDOWN output

tabulate without pandas, and JSON to Markdown

Without pandas, call tabulate directly. Use tablefmt="pipe"; the default format is plain text, not Markdown.

tabulate from a list of rowsPython
# 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:

JSON file to Markdown tablePython
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:

Nested JSONPython
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))
Flattened JSON as a Markdown table
Markdown
|   id | tags      | user.name   | user.email      |
|-----:|:----------|:------------|:----------------|
|    1 | ml, infra | Ada         | ada@example.com |
Preview
idtagsuser.nameuser.email
1ml, infraAdaada@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:

Standard library onlyPython
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:

Render in a notebook cellPython
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.

Input table
Markdown
| Name   | Team     |  Score | Joined     |
| :----- | :------- | -----: | :--------- |
| Ada    | Platform |   92.5 | 2024-03-01 |
| Grace  | Compiler |     88 | 2023-11-15 |
| Hedy   | Radio    |    100 | 2025-01-20 |
Preview
NameTeamScoreJoined
AdaPlatform92.52024-03-01
GraceCompiler882023-11-15
HedyRadio1002025-01-20
Markdown table to DataFrame with read_csvPython
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.5

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

Regex parser for any pipe tablePython
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 need

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

Markdown to HTMLPython
# 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

Errors and surprises, with fixes
SymptomCauseFix
ImportError: Missing optional dependency 'tabulate'tabulate isn’t installed in this environmentpip install tabulate; restart the kernel
Extra 0, 1, 2 columnIndex included by defaultindex=False
1.23457e+06 in a cellDefault "g" float formatfloatfmt=",.2f"
Row split into two linesNewline inside a valueReplace \n with <br> first
Extra column in one rowUnescaped | in a valueReplace | with \| first
nan in cellsMissing values are floatsConvert NaN to None, then missingval="n/a"
Numbers left-alignedtablefmt="github" or polars outputUse the default pipe format, or colalign
Zip codes or IDs right-alignedtabulate treats numeric-looking text as numbersdisable_numparse=True or colalign

Tidy generated tables in the formatter

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.