Markdown Tables in Hugo, Jekyll, MkDocs and Docusaurus
By MarkdownTables.com editorial teamUpdated
On this page
Which Markdown parser does each site generator use?
| Setting | Default | Where |
| :-------- | :------ | :----------- |
| `baseURL` | — | site config |
| `theme` | none | site config || Setting | Default | Where |
|---|---|---|
baseURL | — | site config |
theme | none | site config |
Syntax basics: Markdown table syntax. The generators differ in what they do around it.
Open in editor : The same pipe table works in all four| Generator | Pipe tables | Header row optional | Raw HTML tables | Sortable built in |
|---|---|---|---|---|
| HugoGoldmark with the GFM table extension | Yes | No: A header row is required | Partly: HTML, and unsafe = true for raw HTML | No: Add JavaScript |
| Jekyll / GitHub Pageskramdown | Yes | Yes: Omit the separator line | Yes: Raw HTML is passed through | No: Add JavaScript |
MkDocsPython-Markdown tables extension | Yes | No: A header row is required | Yes: Raw HTML works; md_in_html for Markdown inside | No: See Material below |
| Material for MkDocsMkDocs plus theme features | Yes | No: A header row is required | Yes: Same as MkDocs | Partly: With the tablesort script |
| DocusaurusMDX with GFM tables | Yes | No: A header row is required | Partly: JSX rules: close tags, colSpan, no style strings | No: Add a component |
✅ supported · ⚠️ partly or with a workaround · ❌ not supported · Last verified October 2026
Each generator can be extended with plugins and themes, so “No” means not in the default setup.
Styling is always the theme's job: none of the four gives you borders, striping or widths from the Markdown itself. That is why most of this page is about CSS hooks.
Hugo: Goldmark tables, raw HTML and render hooks
Hugo renders Markdown with Goldmark, and the GitHub-style table extension is on by default. The rules are the GFM rules: a header row, a delimiter row with the same cell count, and a blank line before the table. See the Markdown table syntax guide for the details.
Raw HTML needs unsafe
Since Hugo 0.60, Goldmark omits raw HTML in Markdown files and writes an HTML comment in its place. If your table (or a <br> in a cell, or a centering <div>) vanishes, enable the setting. The name reflects the trade-off: only do this for content you trust.
# hugo.toml
[markup.goldmark.renderer]
unsafe = true # keep raw HTML (otherwise it is replaced by a comment)# hugo.yaml
markup:
goldmark:
renderer:
unsafe: trueShortcodes are the alternative: they run template code, not raw HTML, so they work without unsafe.
A render hook for every table
Hugo 0.134 and later can override how Markdown tables are rendered with a render-table.html hook. It is the clean way to wrap every table in a scroll container or add a theme class without touching the content:
{{/* layouts/_default/_markup/render-table.html (a sketch; check the render hook docs for current fields) */}}
<div class="table-wrap">
<table>
<thead>
{{- range .THead }}
<tr>
{{- range . }}
<th{{ with .Alignment }} style="text-align: {{ . }}"{{ end }}>{{ .Text }}</th>
{{- end }}
</tr>
{{- end }}
</thead>
<tbody>
{{- range .TBody }}
<tr>
{{- range . }}
<td{{ with .Alignment }} style="text-align: {{ . }}"{{ end }}>{{ .Text }}</td>
{{- end }}
</tr>
{{- end }}
</tbody>
</table>
</div>Hugo's Markdown attribute syntax ({.class}) is a separate feature that has to be enabled in the parser settings, and its support for tables has varied. For a class on a single table, wrapping it in an HTML element is the dependable route.
Jekyll: kramdown tables, classes and headerless tables
Jekyll uses kramdown, and GitHub Pages is locked to it. kramdown has its own table syntax with three extras that GFM lacks, and it also accepts the GFM style:
# _config.yml
markdown: kramdown
kramdown:
input: GFM
parse_block_html: true # lets markdown="1" work inside HTML blocks- Classes and IDs through an inline attribute list on the line directly after the table:
| Plan | Price |
|:-----|------:|
| Free | $0 |
| Pro | $12 |
{: .table .table-striped #pricing }- No header. Leave out the separator line. A table with no separator has no
<thead>:
| Version | 2.4.1 |
| Released | 2026-03-02 |
| License | MIT |- A footer. A line of
=signs starts a<tfoot>:
| Item | Cost |
|-------|-----:|
| Tea | 3 |
| Cake | 5 |
|=======|======|
| Total | 8 |These are kramdown only. The same text on GitHub.com, GitLab or Hugo renders differently, so don't copy kramdown tables into a README. Raw HTML in Jekyll Markdown is passed through, and kramdown can parse Markdown inside an HTML block if you set parse_block_html or add markdown="1" to the element. A cell that contains {{ }} or {% %} is processed by Liquid first; wrap it in {% raw %}. GitHub-specific notes are in the GitHub Pages section of the README guide.
MkDocs and Material: Python-Markdown tables
MkDocs uses Python-Markdown. Its tables extension is enabled by default, so pipe tables work without configuration. Two extensions are worth adding:
# mkdocs.yml
markdown_extensions:
- tables # on by default in MkDocs
- md_in_html # Markdown inside <div markdown>
- attr_listPython-Markdown tables need a header row and a blank line before the table. A table inside a list item must be indented by four spaces. The Material theme restyles tables; its documentation covers its table classes and its use of md_in_html for HTML layouts.
Python guides. If you also generate tables from Python code, see Markdown tables in Python.
Docusaurus: tables in MDX
Docusaurus compiles .md and .mdx files as MDX, with GFM tables supported out of the box. MDX treats { as the start of a JavaScript expression and < as the start of JSX, so a table cell can fail to build for reasons that don't exist in other Markdown. If you replace the MDX pipeline, add remark-gfm yourself; the React tables guide covers that setup.
| Instead of | Write | Why |
|---|---|---|
<br> | <br /> | MDX requires every tag to be closed |
<img src="a.png"> | <img src="a.png" /> | Same reason |
A bare { or } in text | \{, or put it inside a code span | Curly braces start a JS expression |
<5 in text | <5 | < starts a JSX tag |
<!-- comment --> | {/* comment */} | HTML comments aren’t valid MDX |
colspan, rowspan | colSpan, rowSpan | JSX attribute names are camelCase |
style="color:red" | style={{ color: "red" }} | JSX takes a style object |
| A pipe in code | \| | The table splits on | first |
Docusaurus 3 can also treat .md files as plain CommonMark through its markdown.format setting, which avoids the JSX rules for those files. Check the current docs for the option on your version.
How do I add a class or set column widths?
Markdown has no syntax for classes or widths, so everything goes through the theme's CSS or a wrapper element. A wrapper with a class is the one technique that works on all four.
| Generator | Class on one table | Column widths |
|---|---|---|
| Hugo | HTML wrapper, a shortcode, or the render-table.html hook | Theme CSS, th:nth-child(n) |
| Jekyll | {: .class } on the line after the table | CSS th:nth-child(n) or the class |
| MkDocs | <div class="x" markdown> (with md_in_html) | extra_css with th:nth-child(n) |
| Docusaurus | <div className="x"> with blank lines around the table | src/css/custom.css |
MkDocs with the Material theme: wrap the table, then give the rule extra weight so Material's default table styles (shrink-to-content) don't win:
<div class="wide-table" markdown>
| Option | Description |
| :------- | :-------------------------------- |
| `site_name` | Shown in the header and title |
| `nav` | Page order and grouping |
</div>/* docs/stylesheets/extra.css (listed under extra_css in mkdocs.yml) */
.md-typeset .wide-table table:not([class]) { display: table; width: 100%; }
.md-typeset .wide-table th:nth-child(1) { width: 25%; }
.md-typeset .wide-table th:nth-child(2) { width: 75%; }For Docusaurus, the blank lines inside the div are what make the table Markdown again:
<div className="wide-table">
| Option | Description |
| :---------- | :--------------------------------------- |
| `routeBasePath` | URL prefix for the docs |
| `sidebarPath` | Path to the sidebar file |
</div>/* src/css/custom.css */
.wide-table table { display: table; width: 100%; }
.wide-table th:nth-child(1) { width: 30%; }To wrap every table at once (for example, to scroll on phones), map the table element in src/theme/MDXComponents.js:
// src/theme/MDXComponents.js
import MDXComponents from '@theme-original/MDXComponents';
export default {
...MDXComponents,
// wrap every Markdown table in a scroll container
table: (props) => (
<div style={{ overflowX: 'auto' }}>
<table {...props} />
</div>
),
};More options are in column width and table styling.
Tables without a header, and merged cells
Of these four, only kramdown (Jekyll) supports a table with no header row in Markdown syntax. In Hugo, MkDocs and Docusaurus, pick one of two workarounds:
- An empty header row plus CSS that hides it:
| | |
| :-- | :-- |
| Version | 2.4.1 |
| Released | 2026-03-02 || Version | 2.4.1 |
| Released | 2026-03-02 |
The preview shows the blank header; the CSS below hides it on your site.
/* Hide a header row whose cells are all empty (needs :has() support) */
thead:not(:has(th:not(:empty))) { display: none; }- An HTML table with only
<tr><td>rows. This also gives you merged cells. In Docusaurus use JSX attribute names:
<table>
<thead>
<tr>
<th rowSpan={2}>Plan</th>
<th colSpan={2}>Limits</th>
</tr>
<tr>
<th>Users</th>
<th>Storage</th>
</tr>
</thead>
<tbody>
<tr><td>Team</td><td>10</td><td>100 GB</td></tr>
</tbody>
</table>Merged cells need HTML on every generator. In Hugo that means unsafe = true, and in MkDocs md_in_html if the cells contain Markdown. See merged cells in Markdown tables and why a table might not render.
Can these tables be sortable?
None of the four sorts tables on its own, because Markdown renders to static HTML. They all accept a small JavaScript library:
- MkDocs Material: its documentation describes loading
tablesortand applying it to every table that has no class:
# mkdocs.yml (Material for MkDocs)
extra_javascript:
- https://unpkg.com/tablesort@5.3.0/dist/tablesort.min.js
- javascripts/tablesort.js// docs/javascripts/tablesort.js
document$.subscribe(function () {
var tables = document.querySelectorAll("article table:not([class])");
tables.forEach(function (table) {
new Tablesort(table);
});
});- Hugo and Jekyll: add the script to your base layout and call it on tables with a chosen class.
- Docusaurus: use a client-side component, such as one registered through MDX components.
- Sort the source instead. The Markdown table sorter orders rows before you commit. See sortable tables.
Generating tables from data files
When the same data appears in several pages, keep it in a data file and generate the table. You get one source of truth and no hand-aligned pipes.
Hugo: a data file and a shortcode
# data/plans.yaml
- name: Free
price: "$0"
seats: 1
- name: Pro
price: "$12"
seats: 10{{/* layouts/shortcodes/plans-table.html */}}
<div class="table-wrap">
<table>
<thead>
<tr><th>Plan</th><th>Price</th><th>Seats</th></tr>
</thead>
<tbody>
{{- range site.Data.plans }}
<tr><td>{{ .name }}</td><td>{{ .price }}</td><td>{{ .seats }}</td></tr>
{{- end }}
</tbody>
</table>
</div>{{< plans-table >}}Jekyll: _data and a Liquid loop
{% comment %} _includes/plans-table.html, data in _data/plans.yml {% endcomment %}
{% assign plans = site.data.plans | sort: "price" %}
<table>
<thead>
<tr><th>Plan</th><th>Price</th></tr>
</thead>
<tbody>
{% for plan in plans %}
<tr><td>{{ plan.name }}</td><td>{{ plan.price }}</td></tr>
{% endfor %}
</tbody>
</table>{% include plans-table.html %}MkDocs: the table-reader plugin
The mkdocs-table-reader-plugin package reads CSV, Excel, YAML and other files and inserts them as Markdown tables. Install it with pip, enable it, and call it from a page; check its documentation for how paths are resolved:
# mkdocs.yml
plugins:
- search
- table-reader{{ read_csv('tables/plans.csv') }}Docusaurus: import JSON and map it
import plans from './plans.json';
<table>
<thead>
<tr><th>Plan</th><th>Price</th></tr>
</thead>
<tbody>
{plans.map((p) => (
<tr key={p.name}><td>{p.name}</td><td>{p.price}</td></tr>
))}
</tbody>
</table>If the data starts life in a spreadsheet, convert it once with CSV to Markdown table or Excel to Markdown table and paste the result, or turn Markdown tables into JSON with Markdown table to JSON for a data file.
Common problems
| Symptom | Cause | Fix |
|---|---|---|
Hugo: <!-- raw HTML omitted --> where a table was | Goldmark drops raw HTML by default | Set markup.goldmark.renderer.unsafe = true, or use a shortcode |
| Jekyll: table shows as text | No blank line before it, or the row doesn’t start with | | Add a blank line; start every row with a pipe |
Jekyll: {{ … }} disappears from a cell | Liquid processed it | Wrap it in {% raw %}…{% endraw %} |
| MkDocs: table shows as text | No blank line before the table, or it is indented under a list item | Add a blank line; indent a table in a list by four spaces |
| MkDocs: Markdown inside an HTML table isn’t rendered | HTML blocks aren’t parsed by default | Enable md_in_html and add markdown to the element |
Docusaurus: build fails on <br> | MDX needs self-closed tags | Write <br /> |
| Docusaurus: “Could not parse expression” on a cell | A { or < in plain text is read as JSX | Escape as \{, <, or put it in backticks |
Docusaurus: colspan warning | JSX uses camelCase attributes | Write colSpan and rowSpan |
| Any: a pipe splits a cell | The parser splits on | before reading code | Escape as \| |
For a table that won't render anywhere, run it through the Markdown table validator. To write the table in a grid and copy clean Markdown, use the table generator.
Frequently asked questions
How do I make a table without a header in MkDocs?
You can’t with pipe syntax: Python-Markdown’s tables extension needs a header row. Either leave the header cells empty (| | |) and hide the empty row with CSS, or write the table as HTML (<table> with <tr><td> rows), adding md_in_html if you need Markdown inside.
How do I set column widths in an MkDocs table?
Add CSS: list a stylesheet under extra_css and set width on th:nth-child(n). Material for MkDocs makes tables shrink to their content, so also set display: table; width: 100% on the table. Wrapping the table in <div class="wide-table" markdown> gives you a class to target.
Can an MkDocs table be sortable?
Yes, with a script. Material for MkDocs documents loading the tablesort library through extra_javascript and calling new Tablesort(table) on each table. Plain MkDocs themes need the same script added to the theme.
How do I change a table’s column width in Docusaurus?
Add CSS in src/css/custom.css, for example th:nth-child(1) { width: 30% }, and scope it by wrapping the table in a <div className="wide-table"> with blank lines around the table. Markdown pipe syntax has no width setting.
How do I add a CSS class to a table in Hugo?
Wrap the table in an HTML element with a class and style it from your theme CSS, or override the table render hook (render-table.html) to output the class. Both beat editing the generated HTML. Raw HTML in content requires unsafe = true in the Goldmark config.
Does Jekyll’s kramdown support tables without a header?
Yes. In kramdown, omit the |---|---| separator line and the table is rendered without a <thead>. A line of |===|===| starts a footer. Add classes with an inline attribute list, {: .table }, on the line right after the table.
Why does <br> break my table in Docusaurus?
Docusaurus compiles pages as MDX, which requires every tag to be closed. Write <br /> instead of <br>. Also escape stray { and < characters in text, because MDX reads them as JSX.
Can I merge cells in a Markdown table on these site generators?
Not with pipe syntax on any of them. Write an HTML table with colspan and rowspan (colSpan and rowSpan in Docusaurus MDX). Hugo needs unsafe = true for raw HTML in Markdown files. See the merged cells guide.
Check the table before you build the site
Paste the Markdown to find cell-count mismatches and other problems that stop a table from rendering.