Skip to content

Markdown Tables in Hugo, Jekyll, MkDocs and Docusaurus

By MarkdownTables.com editorial teamUpdated

On this page

Which Markdown parser does each site generator use?

The same pipe table works in all four
Markdown
| Setting   | Default | Where        |
| :-------- | :------ | :----------- |
| `baseURL` | —       | site config  |
| `theme`   | none    | site config  |
Preview
SettingDefaultWhere
baseURL—site config
themenonesite 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
Tables across static site generators
GeneratorPipe tablesHeader row optionalRaw HTML tablesSortable built in
HugoGoldmark with the GFM table extensionYesNo: A header row is requiredPartly: HTML, and unsafe = true for raw HTMLNo: Add JavaScript
Jekyll / GitHub PageskramdownYesYes: Omit the separator lineYes: Raw HTML is passed throughNo: Add JavaScript
MkDocsPython-Markdown tables extensionYesNo: A header row is requiredYes: Raw HTML works; md_in_html for Markdown insideNo: See Material below
Material for MkDocsMkDocs plus theme featuresYesNo: A header row is requiredYes: Same as MkDocsPartly: With the tablesort script
DocusaurusMDX with GFM tablesYesNo: A header row is requiredPartly: JSX rules: close tags, colSpan, no style stringsNo: 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.tomlTOML
# hugo.toml
[markup.goldmark.renderer]
  unsafe = true   # keep raw HTML (otherwise it is replaced by a comment)
hugo.yamlYAML
# hugo.yaml
markup:
  goldmark:
    renderer:
      unsafe: true

Shortcodes 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:

A table render hookGo template
{{/* 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.ymlYAML
# _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:
kramdown: a class and an id on a tableMarkdown
| 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>:
kramdown: a headerless table (no separator line)Markdown
| Version  | 2.4.1       |
| Released | 2026-03-02  |
| License  | MIT         |
  • A footer. A line of = signs starts a <tfoot>:
kramdown: footer rowMarkdown
| 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.ymlYAML
# mkdocs.yml
markdown_extensions:
  - tables        # on by default in MkDocs
  - md_in_html    # Markdown inside <div markdown>
  - attr_list

Python-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.

MDX rules that affect table cells
Instead ofWriteWhy
<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 spanCurly braces start a JS expression
<5 in text&lt;5< starts a JSX tag
<!-- comment -->{/* comment */}HTML comments aren’t valid MDX
colspan, rowspancolSpan, rowSpanJSX 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.

Classes and widths per generator
GeneratorClass on one tableColumn widths
HugoHTML wrapper, a shortcode, or the render-table.html hookTheme CSS, th:nth-child(n)
Jekyll{: .class } on the line after the tableCSS 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 tablesrc/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:

MkDocs: wrapper div with the markdown attributeMarkdown
<div class="wide-table" markdown>

| Option   | Description                       |
| :------- | :-------------------------------- |
| `site_name` | Shown in the header and title |
| `nav`       | Page order and grouping      |

</div>
MkDocs Material: widths for the wrapped tableCSS
/* 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:

Docusaurus: wrapper with classNameMDX
<div className="wide-table">

| Option      | Description                              |
| :---------- | :--------------------------------------- |
| `routeBasePath` | URL prefix for the docs                |
| `sidebarPath`   | Path to the sidebar file               |

</div>
Docusaurus: custom.cssCSS
/* 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:

Docusaurus: wrap all Markdown tablesJavaScript
// 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:

  1. An empty header row plus CSS that hides it:
An empty header row
Markdown
|     |     |
| :-- | :-- |
| Version  | 2.4.1 |
| Released | 2026-03-02 |
Preview
Version2.4.1
Released2026-03-02

The preview shows the blank header; the CSS below hides it on your site.

Hide an empty header rowCSS
/* Hide a header row whose cells are all empty (needs :has() support) */
thead:not(:has(th:not(:empty))) { display: none; }
  1. An HTML table with only <tr><td> rows. This also gives you merged cells. In Docusaurus use JSX attribute names:
Docusaurus: merged header cellsMDX
<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 tablesort and applying it to every table that has no class:
mkdocs.ymlYAML
# mkdocs.yml (Material for MkDocs)
extra_javascript:
  - https://unpkg.com/tablesort@5.3.0/dist/tablesort.min.js
  - javascripts/tablesort.js
docs/javascripts/tablesort.jsJavaScript
// 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.yamlYAML
# data/plans.yaml
- name: Free
  price: "$0"
  seats: 1
- name: Pro
  price: "$12"
  seats: 10
layouts/shortcodes/plans-table.htmlGo template
{{/* 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>
In a content fileMarkdown
{{< plans-table >}}

Jekyll: _data and a Liquid loop

_includes/plans-table.htmlLiquid
{% 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>
In a pageMarkdown
{% 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.ymlYAML
# mkdocs.yml
plugins:
  - search
  - table-reader
In a pageMarkdown
{{ read_csv('tables/plans.csv') }}

Docusaurus: import JSON and map it

A table from a JSON fileMDX
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

Symptoms and fixes
SymptomCauseFix
Hugo: <!-- raw HTML omitted --> where a table wasGoldmark drops raw HTML by defaultSet markup.goldmark.renderer.unsafe = true, or use a shortcode
Jekyll: table shows as textNo 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 cellLiquid processed itWrap it in {% raw %}…{% endraw %}
MkDocs: table shows as textNo blank line before the table, or it is indented under a list itemAdd a blank line; indent a table in a list by four spaces
MkDocs: Markdown inside an HTML table isn’t renderedHTML blocks aren’t parsed by defaultEnable md_in_html and add markdown to the element
Docusaurus: build fails on <br>MDX needs self-closed tagsWrite <br />
Docusaurus: “Could not parse expression” on a cellA { or < in plain text is read as JSXEscape as \{, &lt;, or put it in backticks
Docusaurus: colspan warningJSX uses camelCase attributesWrite colSpan and rowSpan
Any: a pipe splits a cellThe parser splits on | before reading codeEscape 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.

Open the validator