Markdown tables: syntax, alignment, and tricks that actually work

Published October 7, 2026

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:

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 &#124; 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:

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:

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

PlatformEngineNotes
GitHubGFM (the reference implementation)Follows the spec above. Raw HTML is sanitized but <br>, and colspan/rowspan in HTML tables, are allowed.
GitLabGitLab Flavored Markdown, built on GFMSame pipe-table syntax. GitLab also documents its own extension for rendering a table from a JSON code block.
VS Code previewmarkdown-itSupports GFM tables, alignment and <br> in cells.
HugoGoldmarkTables enabled by default; raw HTML such as <br> omitted unless unsafe is enabled.
Docusaurus and other MDX sitesMDX with remark-gfmTables work; inline HTML must be valid JSX (<br />).
PandocPandoc MarkdownPipe 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.

Try it: paste your table into the Markdown Editor to see the GFM rendering live as you type, including alignment, escaped pipes and <br> line breaks. It runs in your browser, so drafts are never uploaded.

Related articles