How to Convert CSV and Excel to Markdown Tables
Convert CSV to markdown tables with Pandoc, Python, or a paste-in generator — plus how to get Excel to markdown, escape pipes in cell data, and know when a table is the wrong answer.
Short answer: To convert CSV to markdown, run pandoc -f csv -t gfm data.csv. For Excel to markdown, export the sheet as CSV first (Pandoc can't read .xlsx), or use Python: pd.read_excel("book.xlsx").to_markdown(index=False) with pandas and tabulate installed. Whichever tool you use, check your data for pipe characters — they silently break markdown tables.
You have tabular data somewhere it can't stay: a spreadsheet, a database export, a CSV a colleague emailed. It needs to become a markdown table in a README, a docs page, a design doc, or a prompt you're feeding to an AI tool. Retyping it is not an option. Here are the approaches that work, ordered by how often you'll reach for them.
Why Convert at All
Three situations account for most of this:
Documentation. A README that lists supported flags, environment variables, or API fields reads better as a table, and a markdown table lives in the repo where the code lives. See how to write a README.md for where tables earn their place.
Static content. Blog posts, wikis, and docs sites written in markdown need the data inline. A linked spreadsheet is a broken link waiting to happen.
AI prompts. Language models parse markdown tables reliably — the structure is explicit and the column headers repeat in every row's context. Pasting raw CSV works, but a markdown table makes the relationships unambiguous. This is part of why AI tools use markdown as their default format.
Method 1: Pandoc (Fastest for CSV)
Pandoc has a CSV reader. On macOS:
brew install pandoc
Then:
pandoc -f csv -t gfm data.csv
Given this input:
Name,Role,Region
Ada,Engineer,EU
Grace,Rear Admiral,US
You get:
| Name | Role | Region |
| ----- | ------------ | ------ |
| Ada | Engineer | EU |
| Grace | Rear Admiral | US |
Write it to a file or straight to your clipboard:
pandoc -f csv -t gfm data.csv -o table.md
pandoc -f csv -t gfm data.csv | pbcopy
Use -t gfm, not -t markdown. This is the one gotcha. Pandoc's markdown writer produces a simple table — columns aligned with whitespace and a row of dashes underneath, no pipes:
Name Role Region
------- ------------------- --------
Ada Engineer EU
That's valid Pandoc markdown, and GitHub, GitLab, and most other renderers will not render it as a table. -t gfm gives you the pipe table everyone expects.
The CSV reader assumes the first row is a header. It handles quoted fields containing commas correctly. If your file is tab-separated, recent Pandoc versions accept -f tsv; if yours errors with Unknown input format tsv, convert to CSV first.
Method 2: Excel to Markdown
Pandoc cannot read .xlsx or .xls. There is no flag for it. You have two real options.
Export to CSV, then convert. In Excel: File > Save As, choose "CSV UTF-8 (Comma delimited)". In Numbers: File > Export To > CSV. In Google Sheets: File > Download > Comma Separated Values. Then run the Pandoc command above.
CSV export only covers the active sheet, and it drops formulas (you get the computed values), cell formatting, and merged cells. For a table destined for markdown, that's usually exactly what you want.
Use Python. If you're doing this more than once, or the workbook has multiple sheets:
pip install pandas openpyxl tabulate
import pandas as pd
df = pd.read_excel("book.xlsx", sheet_name="Sheet1")
print(df.to_markdown(index=False))
openpyxl is what lets pandas read .xlsx. tabulate is what .to_markdown() requires — without it you get an ImportError, and the error message doesn't always make the missing dependency obvious.
index=False suppresses the row-number column. Leave it off and you'll get a leading column of 0, 1, 2, 3 that nobody wants.
Method 3: Python for CSV
The same approach works for CSV, and gives you a place to clean the data before it becomes a table:
import pandas as pd
df = pd.read_csv("data.csv")
df = df[["Name", "Role", "Region"]] # pick and order columns
df = df.sort_values("Name") # sort
df = df.head(20) # limit rows
print(df.to_markdown(index=False))
This is the right tool when the raw export isn't the table you want to publish — wrong column order, columns to drop, rows to filter. Doing that work in pandas is faster than editing a markdown table by hand, and reproducible when the data updates.
One quirk: pandas right-aligns numeric columns by emitting ---: in the separator row and left-aligns text with :---. That's valid GFM and renders fine.
Method 4: Paste-In Table Generators
There is a whole category of web tools where you paste a block of tab-separated data and get a markdown table back. They work well with spreadsheets because copying a range out of Excel, Numbers, or Google Sheets puts tab-separated text on your clipboard — so you can select cells, copy, paste into the generator, and copy the markdown out.
For a one-off table of a dozen rows this is genuinely the fastest path. Two caveats:
- You're pasting your data into someone else's web page. Fine for a list of API endpoints. Not fine for customer records, salary data, or anything under an NDA. Some of these tools run entirely in the browser with no upload, but you have to verify that rather than assume it.
- They don't scale. Two hundred rows is a bad clipboard experience and a worse review experience.
The same caution applies as with any online converter: if you wouldn't email the file to a stranger, run the conversion locally.
Escaping Pipes: The One That Bites
Markdown tables use | as the column separator. If a pipe appears inside your data, the table breaks — and it breaks silently, which is the dangerous part.
Take this CSV:
Command,Description
"ls | grep foo",Filter listing
Both Pandoc and pandas produce this:
| Command | Description |
| ------------- | -------------- |
| ls | grep foo | Filter listing |
The data row now has three pipes' worth of columns instead of two. GFM truncates extra cells to match the header count, so the rendered table shows ls in the first column, grep foo in the second, and "Filter listing" disappears entirely. No error, no warning. A cell of data is just gone.
The fix is to escape the pipe as \|:
| Command | Description |
| -------------- | -------------- |
| ls \| grep foo | Filter listing |
That renders as a single cell containing ls | grep foo.
Escape before conversion, in the source data:
import pandas as pd
df = pd.read_csv("data.csv")
df = df.map(lambda v: v.replace("|", r"\|") if isinstance(v, str) else v)
print(df.to_markdown(index=False))
The \| escape is required even inside a code span. Inside a table, GFM processes the pipe as a delimiter before it processes the backtick, so `ls | grep` still splits the cell. You have to write `ls \| grep`.
If a converter you're using doesn't offer escaping, | is the HTML entity fallback and renders as a pipe in most viewers.
Anything else that contains pipes — regexes, shell commands, union types like string | null, Mermaid syntax — needs the same treatment. Full details on cell content rules are in the markdown tables syntax guide.
The GFM Table Rules
Whatever generates your table, the output must satisfy a few rules:
- A header row, a separator row, then data rows. The separator is mandatory — a table without it renders as plain paragraphs.
- The separator needs at least three dashes per column:
| --- | --- |. - Every row must have the same number of columns as the header. Extra cells are dropped; missing cells render empty.
- Leading and trailing pipes are optional but conventional, and much easier to read.
- Alignment comes from colons in the separator:
:---left,:---:center,---:right. - Cell padding doesn't affect rendering. Aligning the source is purely for humans reading the raw file.
- Tables must be preceded by a blank line. A table pressed directly against a paragraph above it often won't render.
Tables are a GFM extension. They are not in the CommonMark spec, so a strict CommonMark renderer will show your pipe table as literal text. In practice GitHub, GitLab, most static site generators, and most editors support them — but if a table renders as a wall of pipes somewhere, that's why. Markdown extensions: GFM and MDX covers which flavors include what.
When Not to Convert
A markdown table stops being useful somewhere around 20 to 30 rows, or 6 to 8 columns. Past that the source becomes a wall of pipes you can't edit by hand, every data update produces an unreviewable diff, wide tables overflow on mobile, and readers lose the sorting and filtering that a large dataset actually needs.
For anything genuinely large, link to the CSV instead. Commit the .csv next to the markdown file and let readers open it in a tool built for tabular data — GitHub renders committed CSV files as a searchable, sortable table on its own.
The middle ground: keep the CSV as the source of truth and generate the markdown table as a build step. The data has one home, and the table in your docs is derived rather than maintained.
Once it's converted, check it. OpenMark renders GFM tables live, so a missing column or a swallowed cell shows up immediately rather than after you've pushed.
If your data is JSON rather than CSV, the approach is similar but nesting complicates things — see how to convert JSON to markdown.
Download OpenMark → — $9.99, one-time, native macOS. Renders GFM tables, code blocks, and diagrams live, so you can verify a converted table before it ships.