Markdown Tables Explained
A Markdown table looks like the thing it produces, which is exactly why people get it wrong: the pipes and dashes read as drawing, so it feels like the rules are about lining characters up by eye. They are not. A GitHub-Flavored-Markdown table is a small, strict grammar — two mandatory rows, an alignment syntax that lives entirely inside the second one, one character you must escape, and precisely documented behaviour when a row has the wrong number of cells. Every claim below about what a renderer actually does is traceable to section 4.10 of the GFM spec rather than to us.
The three parts of a table
The spec defines a table as “an arrangement of data with rows and columns, consisting of a single header row, a delimiter row separating the header from the data, and zero or more data rows.” That is the whole shape, and it is worth reading twice, because it tells you which parts are optional. The data rows are optional. The header row and the delimiter row are not.
Delete the second line and you do not get a table with no formatting — you get a paragraph containing literal pipe characters, because nothing told the parser a table had started. There is also no such thing as a headerless GFM table: you may leave the header text blank by writing | | |, but you cannot remove the row. Going the other way, a header and a delimiter row with nothing under them is perfectly valid and renders as a table with a head and no body.
One more rule catches people out: the header row must match the delimiter row in cell count. The spec is blunt — “if not, a table will not be recognized.” That is not a broken table, it is no table at all, and the source appears verbatim in the output:
The leading and trailing pipes are the one genuinely optional piece of punctuation. The spec calls them “recommended for clarity of reading, and if there’s otherwise parsing ambiguity” — a polite way of saying: keep them.
Alignment: everything happens in the delimiter row
Column alignment is not set on the header and not set per cell. It lives entirely in the delimiter row, whose cells the spec describes as “cells whose only content are hyphens (-), and optionally, a leading or trailing colon (:), or both, to indicate left, right, or center alignment respectively.” That gives you exactly four forms:
| Delimiter cell | Meaning | HTML emitted |
|---|---|---|
--- | no alignment specified | <th> with no align |
:--- | left | <th align="left"> |
---: | right | <th align="right"> |
:---: | centre | <th align="center"> |
The number of hyphens is irrelevant — one is enough. The spec's own example writes a centred column as :-:. Here is a source-and-rendered pair; this is the source:
…and this is what the renderer emits for the header (body cells inherit the same align attribute):
Note what alignment actually is: an HTML attribute, not a layout guarantee. GitHub styles it, but a site whose CSS sets text-align on table cells will override it, and some minimal renderers drop the attribute entirely. The colons are always valid syntax; they are a request that the renderer is free to ignore.
Column widths matter to reviewers, not to the parser
The spec settles this in one sentence: “spaces between pipes and cell content are trimmed,” and adds that “cells in one column don’t need to match length, though it’s easier to read if they are.” The padded table above and this cramped one produce byte-identical HTML:
So why pad at all? Because raw Markdown gets read by humans — in an editor, in a diff, in a pull request — and a table you can scan as columns is much easier to check for a value in the wrong place. But padding has a specific cost: it couples every row to the widest value in its column. Change one cell from 12 to 1200 in a padded table and you must re-pad every other row to keep the alignment, so a one-cell edit arrives in review as a diff that rewrites all four lines; the same edit in an unpadded table is a one-line diff. Pad a table that is read far more often than it is edited, skip it for one that changes weekly. Padding is a courtesy to reviewers, not a requirement.
The one character you must escape
The pipe is the cell separator, so a pipe inside cell content has to be escaped as \|. The spec’s wording is worth quoting because of its final clause: “include a pipe in a cell’s content by escaping it, including inside other inline spans.” That last part is the surprise. Normally a backtick code span is taken literally, so you would expect `|` to be safe; inside a table it is not, and you must write `\|` instead.
Forget an escape and the failure is quiet rather than loud: the cell splits in two and every value after it shifts one column left, so a four-column row silently becomes a five-column one. Regular expressions, shell pipelines and boolean-OR syntax are the usual victims, because they are made of pipes.
Backslashes themselves follow the CommonMark rule that GFM inherits: “any ASCII punctuation character may be backslash-escaped,” and “backslashes before other characters are treated as literal backslashes.” So \d in a regex cell survives untouched — d is a letter, not punctuation — and a Windows path like C:\temp comes through as typed. But \* and \_ are escapes and will lose their backslash in the output. If a cell genuinely needs a literal backslash immediately before a pipe, escape the backslash first and the pipe second: \\\|. That ordering is not optional — escape the pipes first and the backslash pass will then double the backslash you just added.
Rows with the wrong number of cells
Real data is ragged, and the spec is explicit about both directions. Section 4.10: “the remainder of the table’s rows may vary in the number of cells. If there are a number of cells fewer than the number of cells in the header row, empty cells are inserted. If there are greater, the excess is ignored.” Here is the spec’s own worked case, a two-column header with one short row and one long one:
The short-row case is benign — you see a blank and can fill it. The long-row case is the one to fear, because nothing in the output tells you a value was thrown away. Paste a spreadsheet column too many into a README and boo vanishes on GitHub with no warning, no error and no visual gap.
This is where Polyatic’s Markdown Table Generator deliberately parts company with the renderer. On import it keeps the extra cell and widens the header by one column, and the import note says exactly that — how many rows carried more cells than the header, that a column was added, and that a renderer such as GitHub ignores those cells, so your pasted source shows fewer columns there than the grid does here. Short rows are still padded with empty cells, matching the spec, and the note counts the two cases separately because they are opposite edits. Losing a cell someone typed is worse than showing them one column more than their source rendered — and being told about the difference beats both.
What a cell cannot hold
The spec is one sentence long on this: “block-level elements cannot be inserted in a table.” Cells take inline content only — **bold**, *italic*, links, images and `code` spans all work. Bullet lists, numbered lists, headings, blockquotes and fenced code blocks do not, and neither does a paragraph break. A cell also cannot span source lines, because the newline ends the row.
The standard workaround is a literal <br> inside the cell, which GitHub passes through to the output:
Be honest about what that buys you: line breaks, not structure. - one<br>- two renders as two lines that begin with hyphens, not as a <ul>. And it is raw HTML, not Markdown — a renderer that sanitises or escapes HTML will show the tag or drop it. If a cell really needs a list, the table is the wrong shape for that data.
A related trap: the spec says “the table is broken at the first empty line, or beginning of another block-level structure.” Until one of those arrives, a plain line of text directly under a table is swallowed into it as a row. Leave a blank line after every table.
Where tables work, and where they do not
Tables are not part of core CommonMark. The GFM spec calls itself “a strict superset of CommonMark” whose extra features “are hence known as extensions,” and tables are one of them — which is why the table rules live in section 4.10 under the heading “Tables (extension)”.
They render anywhere GitHub renders user Markdown: READMEs and .md files browsed on the site, issues, pull request descriptions, review comments and wikis. GitLab’s Markdown is likewise a GFM superset. Beyond those it depends entirely on whether the renderer has the extension switched on — remark-gfm, markdown-it with tables enabled, Python-Markdown’s tables extension and cmark-gfm with the table flag all support them, and every one of those is an opt-in.
Feed the same source to a strict CommonMark parser with no extensions and you get a paragraph of literal pipe characters — not an error, just your table printed as text. If you do not control the renderer, a plain HTML <table> is the portable fallback: CommonMark passes raw HTML blocks straight through, and so does GFM.
Edit a grid, get valid GFM out — pipes and backslashes escaped in the right order, alignment colons per column, and a Markdown/CSV/TSV importer that tells you what it changed. Runs entirely in your browser.
Putting it together
A GFM table is three stacked parts, two of which are mandatory: header, delimiter, body. Alignment is set once per column in the delimiter row with a leading colon, a trailing colon, both, or neither, and it emits an align attribute that a renderer may ignore. Padding the source columns changes nothing for the parser and everything for a diff, so treat it as a deliberate trade rather than a default. Escape a literal pipe as \| — even inside a code span — and remember that a backslash is only special in front of ASCII punctuation. And know the two ragged-row rules from section 4.10 cold, because they behave differently: a short row gains empty cells, while a long row loses its excess with no warning at all. That last one is the reason to check a hand-written table against something that reads the spec for you.
Open the Markdown Table Generator → to build or import a table, or browse all Polyatic tools.