Tables are one of the most used Markdown features, yet they aren't part of core CommonMark. They come from the GitHub Flavored Markdown (GFM) tables extension, which most renderers now implement in some form. The syntax is simple, but the edge cases trip people up: pipes inside code, line breaks in cells, merged cells, and tables that render on GitHub but not in your static site.
This guide covers the rules as the GFM spec defines them, the workarounds that hold up, and where renderers differ. Every example can be pasted into the Markdown Editor to see the rendered result next to the source.
The basic syntax
A table has a header row, a delimiter row, and zero or more body rows. Cells are separated by pipes
(|):
| Method | Path | Auth |
| ------ | ----------- | ---- |
| GET | /users | no |
| POST | /users | yes |
| DELETE | /users/{id} | yes |
The rules that matter in practice, all from the GFM spec:
- The delimiter row is required. Each of its cells contains hyphens, optionally with a colon at either end. One hyphen is enough; padding the dashes to the column width only helps readability.
- The header and delimiter rows must have the same number of cells. If they don't, there is no table, and the lines render as a paragraph full of pipes.
- Leading and trailing pipes are optional.
a | bfollowed by--|--is a valid table. Including the outer pipes is still clearer, and it's required for single-column tables. - Body rows don't need to match the column count. Short rows are padded with empty cells, and extra cells are silently dropped.
- A table ends at a blank line or the start of another block, such as a heading or list. A plain line of text directly below a table becomes another row, so always leave a blank line after a table.
- Cells hold inline content only: emphasis, links, inline code and images work. Lists, headings, fenced code blocks and blockquotes don't.
A header row is mandatory. If you want a table without visible headings, leave the header cells empty
(| | |), but most renderers still draw an empty header row.
Column alignment
Colons in the delimiter row set the alignment of the whole column:
| Left | Center | Right |
|:---------|:--------:|---------:|
| apples | 12 | $1.20 |
| cherries | 140 | $18.75 |
A colon on the left means left-aligned, colons on both sides mean centered, and a colon on the right means
right-aligned. With no colon, the renderer's default applies, usually left. Renderers emit this as an
align attribute or an inline text-align style on each <th>
and <td>, so it carries through to HTML exports. Right-align numeric columns so the
digits line up; it makes a large difference to how fast readers can compare values.
Escaping pipes inside cells
Any unescaped | starts a new cell, so write a literal pipe as \|:
| Shell idiom | Meaning |
| ------------------ | ------------------------- |
| `cmd1 \| cmd2` | pipe output of cmd1 |
| `a \|\| b` | run b only if a fails |
The surprising part is that this applies inside inline code too. In most Markdown, a backslash
in a code span is shown literally, but GFM splits table rows into cells before it parses inline
content, so the pipe would end the cell first. The spec makes an exception: \| inside a
code span in a table renders as a plain | with no backslash.
Here is what happens if you forget. This row:
| `a || b` | logical OR |
produces two cells, `a and an empty one, and the rest is discarded because the header only
has two columns. The backtick never closes, so it appears as a literal character.
The HTML entity | also produces a pipe in ordinary cell text, because entities are
decoded after the row is split. Inside a code span it doesn't work, since code spans show entities
literally. Stick with \|.
Line breaks and multi-line content in cells
Each table row must sit on a single source line, and a newline always ends the row. For a visible line
break inside a cell, use an HTML <br> tag:
| Step | Notes |
| ---- | -------------------------------------------- |
| 1 | Install the CLI.<br>Requires Node 20 or later. |
| 2 | Run `init` in the project root. |
This works anywhere raw HTML is allowed, which includes GitHub, GitLab and VS Code's preview. It's not universal, though:
- Hugo uses the Goldmark renderer, which by default omits raw HTML
(
markup.goldmark.renderer.unsafe = false). Your<br>becomes an<!-- raw HTML omitted -->comment unless you enable it. - MDX (Docusaurus and similar) parses HTML as JSX, so you have to write the
self-closing
<br />.
The same trick extends to short lists, such as <ul><li>one</li><li>two</li></ul>
inside a cell, which GitHub renders. But a cell that needs a list is usually a sign the content wants to
be a section of prose under a heading, with the table linking to it.
Why there are no merged or multi-line cells
GFM tables have no syntax for colspan, rowspan, or cells that span several
source lines. That's deliberate: the format is designed so that the source is itself a readable table,
one line per row, and spanning cells break that grid. Some other Markdown dialects add extensions.
MultiMarkdown uses consecutive pipes for column spans, and Pandoc's grid tables allow multi-line cells.
None of these work on GitHub.
When you genuinely need merged cells, write an HTML table. GitHub, GitLab and most site generators render it, as long as raw HTML is allowed:
<table>
<tr><th rowspan="2">Region</th><th colspan="2">Latency (ms)</th></tr>
<tr><th>p50</th><th>p99</th></tr>
<tr><td>eu-west</td><td>38</td><td>210</td></tr>
<tr><td>us-east</td><td>41</td><td>185</td></tr>
</table>
One CommonMark rule catches people here. Markdown inside an HTML block isn't processed unless it is
separated from the tags by blank lines. If you need **bold** or a link inside an HTML table
cell, either use HTML (<strong>, <a href>) or put the Markdown on
its own lines with blank lines around it.
Dealing with wide tables
On GitHub, a table wider than the content column scrolls horizontally. Other renderers may overflow the page or squeeze the columns. Before you reach for CSS, consider whether the table should change shape:
- Transpose it. A table with two rows and twelve columns reads better as twelve rows of key/value pairs.
- Split it into several tables grouped by topic, each with its own heading.
- Move long text out of cells. Keep cells to a few words and put explanations in a list or footnotes below the table.
- Use short column headers and explain abbreviations once, under the table.
Generating tables from CSV or JSON
Hand-typing a large table is slow and error-prone. If the data already exists, generate the Markdown. This JavaScript function turns an array of flat objects, such as an API response, into a GFM table. It escapes the two characters that break table structure, pipes and newlines, and escapes backslashes so the output reads back the same way:
function toMarkdownTable(rows, columns = Object.keys(rows[0] ?? {})) {
const cell = (value) => {
if (value === null || value === undefined) return '';
const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
return text
.replace(/\\/g, '\\\\') // keep literal backslashes
.replace(/\|/g, '\\|') // pipes would split the cell
.replace(/\r?\n/g, '<br>'); // newlines would end the row
};
const header = `| ${columns.map(cell).join(' | ')} |`;
const divider = `| ${columns.map(() => '---').join(' | ')} |`;
const body = rows.map((row) => `| ${columns.map((c) => cell(row[c])).join(' | ')} |`);
return [header, divider, ...body].join('\n');
}
console.log(toMarkdownTable([
{ flag: '--verbose', type: 'boolean', description: 'Print every request' },
{ flag: '--format', type: 'json | yaml', description: 'Output format.\nDefaults to json.' },
]));
| flag | type | description |
| --- | --- | --- |
| --verbose | boolean | Print every request |
| --format | json \| yaml | Output format.<br>Defaults to json. |
The function doesn't escape < or &, so values that contain HTML pass
through as HTML. That's fine for your own data, but escape them if the input is untrusted. For nested
JSON, flatten it first; the JSON to CSV converter turns nested objects into
dotted column names, which gives you a flat table to start from.
For CSV, Python's standard library is enough. This version also pads the columns so the generated source is readable:
import csv
import sys
def csv_to_markdown(path):
with open(path, newline="", encoding="utf-8") as f:
rows = list(csv.reader(f))
ncols = len(rows[0])
# pad short rows, escape pipes, flatten newlines
rows = [[(r[i] if i < len(r) else "").replace("|", "\\|").replace("\n", "<br>")
for i in range(ncols)] for r in rows]
widths = [max(3, *(len(r[i]) for r in rows)) for i in range(ncols)]
def fmt(row):
return "| " + " | ".join(c.ljust(w) for c, w in zip(row, widths)) + " |"
lines = [fmt(rows[0]), "| " + " | ".join("-" * w for w in widths) + " |"]
lines += [fmt(r) for r in rows[1:]]
return "\n".join(lines)
print(csv_to_markdown(sys.argv[1]))
| name | port | notes |
| ------ | ---- | ------------------ |
| api | 8080 | public, behind LB |
| worker | | reads a \| b queue |
Keeping tables readable in source
Renderers ignore extra spaces around cell content, so aligning the pipes is purely for the people reading the source. It's worth doing for tables that are edited by hand, and formatters such as Prettier will align Markdown tables automatically.
Alignment has a cost in version control. Adding one long value widens a column and re-pads every row, so a one-cell change shows up as a diff of the whole table. For large, frequently edited tables, some teams leave them unaligned, or keep the data in a CSV or JSON file and generate the table in the build. Pick one convention per repository and let a formatter enforce it.
Rendering differences between platforms
| Platform | Engine | Notes |
|---|---|---|
| GitHub | GFM (the reference implementation) | Follows the spec above. Raw HTML is sanitized but <br>, and colspan/rowspan in HTML tables, are allowed. |
| GitLab | GitLab Flavored Markdown, built on GFM | Same pipe-table syntax. GitLab also documents its own extension for rendering a table from a JSON code block. |
| VS Code preview | markdown-it | Supports GFM tables, alignment and <br> in cells. |
| Hugo | Goldmark | Tables enabled by default; raw HTML such as <br> omitted unless unsafe is enabled. |
| Docusaurus and other MDX sites | MDX with remark-gfm | Tables work; inline HTML must be valid JSX (<br />). |
| Pandoc | Pandoc Markdown | Pipe tables supported, plus grid and multiline tables for richer layouts. |
When a table renders on GitHub but not elsewhere, check for a missing blank line before the table, a header and delimiter row with different cell counts, and raw HTML that the target renderer strips.
<br>
line breaks. It runs in your browser, so drafts are never uploaded.