Merge cells in Markdown tables: HTML and labels

To merge cells in Markdown tables, use complete HTML when the destination retains table tags and span attributes; use repeated labels when the source must stay an ordinary pipe table. The three routes are merged HTML layout, repeated labels for portable rows, and syntax tied to a named renderer or plugin. GFM pipe tables provide no merge syntax. 1

Choose the format by what must survive

Preserve the layout with HTML, preserve each record with repeated labels, or keep a specialized syntax only within its documented renderer.

What you need to preserve How to identify the case Route
A heading over adjacent columns or a label down several rows The final HTML contains the intended spanning cell and keeps its attributes Write a complete HTML table
An editable pipe table that can move between table-capable tools The destination renders pipe tables but displays raw HTML tags as text Repeat grouping labels in ordinary cells
An existing plugin-dependent layout The source contains special merge markers whose meaning comes from that plugin Keep the matching renderer, or flatten before sharing
Records for a CSV export Each exported row needs its own category and field names Use the repeated-label route and flatten grouped headers

A pipe delimiter defines a boundary between cells. An HTML span makes a cell cover positions in the table's grid. A blank pipe cell retains its position. Removing its content does not attach it to a neighbor. The HTML table model describes the occupied positions independently from the number of cell elements in a row. 2

A successful local preview establishes only that preview's behavior. The GFM specification describes additional processing and sanitization on GitHub.com after parsing. A local GFM reader cannot establish which attributes a hosted page will retain. 1

Merge cells in Markdown tables with HTML

A complete HTML table can carry horizontal and vertical spans when the destination preserves its markup.

Use colspan for a cell that covers neighboring columns. Write the entire table as HTML, including the rows beneath the merged heading. In the grouped-header sample, Schedule covers the Morning and Evening columns. Region remains separate. markdown-it-py's CommonMark mode retained the HTML with its pipe-table rule disabled. 2310

Output from our grouped-header CommonMark sample, unedited:

<table>
        <col><colgroup span="2"></colgroup>
        <thead><tr><th scope="col">Region</th><th colspan="2" scope="colgroup">Schedule</th></tr></thead>
        <tbody><tr><th scope="row">North</th><td>Morning</td><td>Evening</td></tr></tbody>
        </table>
        

The column group describes the columns beneath Schedule. The header's scope associates it with that group, while the Region cell labels its own column. Use header cells for labels and data cells for values. WAI's irregular-header guidance explains the column and row groups used in these examples. 9

Use rowspan for a label that continues down adjacent rows. Omit the covered cell from the following row. In the task sample, North labels both Plan and Ship, so the Ship row contains only its task cell. An extra empty cell there would occupy another position instead of acting as a placeholder beneath North. This Pandoc GFM output retains the HTML source. 210

Output from our grouped-row Pandoc sample, unedited:

<table>
        <thead><tr><th scope="col">Region</th><th scope="col">Task</th></tr></thead>
        <tbody>
        <tr><th rowspan="2" scope="rowgroup">North</th><td>Plan</td></tr>
        <tr><td>Ship</td></tr>
        </tbody>
        </table>
        

The body group contains the rows labeled by North. A merged label and a missing value have different meanings: the first identifies a group, while the second leaves data unspecified. 89

Markdown emphasis inside HTML is a separate capability from cell merging. In our Python-Markdown configuration, the spanning heading survived and its Markdown asterisks remained literal. 410

Output from our HTML-emphasis Python-Markdown sample, unedited:

<table>
        <tr><th colspan="2">**Schedule**</th></tr>
        <tr><td>Morning</td><td>Evening</td></tr>
        </table>
        

Use HTML formatting inside an HTML table when the destination follows this behavior. For example, an HTML strong element expresses emphasis without asking a Markdown parser to revisit the cell. Obsidian's documentation explicitly says it does not process Markdown inside HTML elements. It also requires complete HTML blocks without internal blank lines. 5

Locate the failed stage before changing the table

Table recognition, span syntax and HTML handling fail in different ways, so the output should determine the repair.

Visible symptom What the local experiment distinguishes Repair
The whole pipe table remains a paragraph CommonMark mode has no active table rule Enable table parsing in the renderer you control
A table appears, but the intended merged area contains an empty cell Double pipes and a shortened body row both produce an ordinary empty cell Use a full HTML table or repeat the label
A trailing value disappears The body row extends beyond the header's width Repair the column structure before exporting
HTML tags appear as text The HTML-disabled configuration escapes the source Use a destination that permits the required HTML, or flatten
A merge marker appears in a cell The ordinary table parser has no rule for that marker Use the named extension or replace the marker with a label

Our double-pipe and short-row inputs produced the same final body row in the table-enabled configurations, without a spanning cell. 10

Output from our double-pipe markdown-it-py table-enabled sample, unedited:

<tr>
        <td>North</td>
        <td>Shared</td>
        <td></td>
        </tr>
        

The parser fixes the row width from the header before filling body cells. In the installed Python-Markdown source, the row builder iterates over that width and supplies empty text when a cell is missing. The installed markdown-it-py table rule also emits body cells up to the header's width. It does not create a span when the input ends early. 12

In our overfull-row sample, Ship appeared after the fields defined by the header and disappeared from the parsed table. For a damaged source, compare the last value in each row with its intended header before converting it again. 110

With the pipe-table rule still enabled but HTML disabled, markdown-it-py emitted escaped tags for the grouped header. The source included a span attribute, but the output contained text representing that attribute. This setting demonstrates escaping, not the behavior of a platform sanitizer. 310

Output from our HTML-disabled grouped-header sample, unedited:

<p>&lt;table&gt;
        &lt;col&gt;&lt;colgroup span=&quot;2&quot;&gt;&lt;/colgroup&gt;
        &lt;thead&gt;&lt;tr&gt;&lt;th scope=&quot;col&quot;&gt;Region&lt;/th&gt;&lt;th colspan=&quot;2&quot; scope=&quot;colgroup&quot;&gt;Schedule&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
        &lt;tbody&gt;&lt;tr&gt;&lt;th scope=&quot;row&quot;&gt;North&lt;/th&gt;&lt;td&gt;Morning&lt;/td&gt;&lt;td&gt;Evening&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;
        &lt;/table&gt;</p>
        

For an Obsidian Markdown table, treat plugin syntax as a contract with that plugin. Table Extended's own README documents extra pipes for horizontal spans and a double-caret marker for merging upward. It also distinguishes its prefixed table syntax from an experimental native-table option. Table Extended was not executed for this guide. Its markers were supplied only to ordinary local parsers, where the carets remained text. 610

The plugin author's historical PDF-export issue describes a separate export handling path and a footnote failure. That report does not establish a current failure in your installation, and we did not reproduce it. 7

Repeat labels when the table must travel

Repeat grouping labels when the destination needs independent rows or cannot retain the merged HTML structure.

For the task table, North belongs beside every task in the portable source. The table loses its vertically merged appearance, while the relationship remains present in each row. Our table-capable local configurations kept the repeated label. Strict CommonMark mode still treated the pipe syntax as a paragraph, so this fallback also needs a table-capable destination. 10

Input from our repeated-label sample, unedited:

| Region | Task |
        | --- | --- |
        | North | Plan |
        | North | Ship |
        

Output from our repeated-label Python-Markdown sample, unedited:

<tr>
        <td>North</td>
        <td>Ship</td>
        </tr>
        </tbody>
        

Before exporting a Markdown table to CSV, normalize its labels. CSV records contain fields and carry no row-span or column-span instructions. A blank category will remain blank unless the export process explicitly fills it. Repeat a category only when the source establishes that it applies to the following records. Preserve an unknown value as unknown. 8

A heading such as Schedule can become part of each field name, paired with the specific Morning or Evening label. Keep labels descriptive enough to identify the original group after someone sorts the exported rows or moves a column.

Copying labels is different from copying amounts. A total displayed across several columns does not specify an amount for every covered column. Repeating that total would create values the source did not provide. Retain it as a separate summary or preserve the original table alongside the normalized records. The source must supply any allocation rule.

If the table came from PDF to Markdown conversion, compare its grouped headers and category labels with the source PDF. Keep a complete HTML representation when those spans are part of the reading task. Use repeated labels for independent records when the destination needs a plain table. This is a cleanup choice after extraction, with no assumption that conversion preserves every merged cell.

Use HTML to keep the merged layout in an HTML-capable destination. Use explicit labels to keep row meaning when the table moves.

How we tested

The tests isolate local parsing and HTML handling on authored table samples.

We ran the merge-specific experiment on 2026-11-02 with Python 3.11.14, markdown-it-py 4.0.0, Python-Markdown 3.10.2 and Pandoc 3.11. 1011

The pre-existing renderer matrix covered unrelated Markdown syntax as well. This guide reports only the eight added table samples below. The CommonMark configuration is a local implementation, and Pandoc GFM is a local reader. We did not test GitHub.com, VS Code, Obsidian's UI, Table Extended itself, a CSV converter or Markovo conversion. The plugin discussion uses its official README and a dated issue, not a reproduced plugin defect.

Run from the repository root with the already installed renderer dependencies:

.venv/bin/python runs/2026-11-02-markovo-markdown-table-merge-cells/lab/renderers/test_merges.py
        

The script embeds the inputs, writes each Markdown source, runs every input through each configuration and saves raw HTML as text files. It updates the merge-specific rows in the existing results file without replacing the upstream observations. The versions and options used for this run are in its supplement record. 1011

Configuration in the result table Exact parser or command
CM MarkdownIt("commonmark").render(source)
CM + table MarkdownIt("commonmark").enable("table").render(source)
CM + table, HTML off MarkdownIt("commonmark", {"html": False}).enable("table").render(source)
Python-MD markdown.markdown(source, extensions=["tables", "fenced_code"])
Pandoc GFM pandoc -f gfm -t html with the source sent to standard input

No merge plugin, Markdown-in-HTML extension or Pandoc filter was enabled. HTML off measures escaping. It does not model a hosted service's sanitizer.

Inputs

Source file Represented form
merge-double-pipe.md Extra pipe intended as a horizontal merge
merge-short-row.md Body row ending before its last field
merge-overfull-row.md Body row exceeding the header width
merge-plugin-marker.md Upward merge marker without its plugin
merge-html-colspan.md Grouped column heading in HTML
merge-html-rowspan.md Grouped row heading in HTML
merge-html-markdown.md Markdown emphasis inside an HTML heading
merge-flat-labels.md Repeated grouping labels in a pipe table

Structural observations

Each entry is tables / rows / cells / colspan attributes / rowspan attributes / empty cells. Cells count actual th and td start tags, including headers. Rows count tr start tags. Span counts are attribute occurrences, not the number of grid slots they cover. Empty cells have only whitespace as parsed text. Links open the unedited HTML text output for that exact configuration and sample. 10

A zero table count means the parser emitted no table element. It does not mean the source text was lost. The grouped-header and grouped-row spans have a literal attribute value of 2 in the recorded outputs. Cell text is also stored in the results: the double-caret marker remains literal, the overfull row loses Ship, and the HTML-emphasis sample keeps its asterisks.

These are structure observations, not accuracy or visual-fidelity scores. The counter uses Python's HTMLParser and does not lay out a browser grid or test assistive technology. This syntax experiment does not use the PDF scorer and has no OCR, page-coverage or timing measurement.

The raw observations, experiment script and source inspection provide the reproducibility record. The source inspection identifies TableProcessor._build_row in the installed Python-Markdown tables.py, and table in markdown-it-py's rules_block/table.py, with original line numbers and complete-file hashes. The fixed-width cell loops explain the empty and discarded cells independently of the output counts. 12

Sources

  1. GFM specification: tables and additional processing
  2. HTML Standard: table processing model
  3. markdown-it-py: parser configuration
  4. Python-Markdown: Tables extension
  5. Obsidian: HTML content and limitations
  6. Table Extended: author README
  7. Table Extended: historical PDF export issue
  8. RFC 4180: CSV records and fields
  9. WAI: table headers spanning rows or columns
  10. Local renderer observations and configuration
  11. Reproducible merge-specific experiment
  12. Installed table-parser functions and source hashes