HTML tables to Markdown without shifted cells
Convert HTML tables to Markdown as pipe tables when their cells already map to flat fields. Resolve merged cells first, or keep HTML when their structure must survive. The decision turns on four cases: a regular grid with headers, a grid without headers, merged cells, and rich content inside cells. Our local conversions exposed failures that still produced a table: an item moved under the category heading, and an inline code expression caused the final link to disappear.
Choose the representation before converting HTML tables to Markdown
Use a pipe table for flat records, and retain HTML when flattening would change the relationships or content you need.
| Input shape | How to recognize it | Conversion path |
|---|---|---|
| Regular grid with headers | Each body cell belongs under a single heading, with no spanning attributes | Convert to a pipe table, then inspect a body row under its headings |
| Grid without headers | The opening row contains records rather than field names | Supply meaningful headings, or use an empty header without promoting a record |
| Merged cells | The source contains rowspan or colspan, including grouped headings |
Keep HTML, or expand the occupied positions and rewrite the headings |
| Rich cell content | A cell contains a list, paragraphs, or explicit line breaks | Keep HTML when the internal structure matters; use inline text only after deciding what to flatten |
Apply the structural cases before choosing the header policy when several cases overlap. Links and inline code can fit a pipe table, but a pipe inside the code still needs escaping. A list inside a cell requires a different representation from a short inline label. GFM tables accept inline content, not block elements. 1
HTML spans describe positions that a cell occupies, not extra copies of a child element. A category cell can occupy the beginning of a later row even though that row has no category element of its own. Reading each row's child cells from left to right loses that occupied position unless the converter tracks it. The HTML table model defines these occupied slots across rows and columns. 2
Use markdownify for flat cells with an explicit header policy
markdownify is useful for flat tables, but its column-span handling does not reconstruct positions occupied by earlier rows.
markdownify can convert a saved fragment with an explicit parser and header policy. This example leaves an empty header when the input has no heading cells. 3
from pathlib import Path
from markdownify import markdownify
source = Path("input.html").read_text(encoding="utf-8")
result = markdownify(source, bs4_options="html.parser",
table_infer_header=False)
Path("output.md").write_text(result, encoding="utf-8")
Our ordinary grid kept its headings and records. The headerless input also kept both records in the body, underneath an empty header. An empty header satisfies the pipe-table format, but it does not supply missing field names. If the source provides no names, preserve that uncertainty or add names from the surrounding explanation. 8
The source placed East beside Apples and Pears with a spanning cell. Its Markdown output omitted the occupied category position from the Pears row.
Output from our merged-category sample, unedited:
| Group | Item | Qty |
| --- | --- | --- |
| East | Apples | 12 |
| Pears | 9 |
Both local table readers accepted this output. Pears appeared under Group, while its quantity appeared under Item. The readers added the missing empty cell at the end. 8
markdownify's cell handlers add separators for a column span, while its row handler emits the cells it receives without maintaining a map of positions occupied from earlier rows. A spanning cell on the right can leave only a trailing gap; the same shape on the left shifts the fields that follow. The reported rightmost-span example in the project's issue tracker also produced a short row in our run. 46
| Boundary | Visible result | Required change |
|---|---|---|
| A category spans later rows | Later records start under the category heading | Insert the category position before converting, or keep HTML |
| A heading spans leaf columns | A parent label and a lower heading row become separate flat rows | Build a single row of compound headings |
| A cell contains line breaks or a list | Text remains, but breaks or list structure disappear | Accept that flattening explicitly, or use HTML |
Use Pandoc when the table's HTML structure must survive
Pandoc's GFM writer retained HTML for the merged and block-content samples, preserving structure that a flat pipe table could not express.
Pandoc can write ordinary grids as pipe tables while allowing more complex tables to remain HTML:
pandoc -f html -t gfm --wrap=none input.html -o output.md
Our ordinary and headerless grids became pipe tables. The grouped heading and spanning category remained HTML with their span attributes. The cell containing an explicit break kept that break. The cell containing a list kept its list elements instead of turning the bullets into text. 8
Output from our list-inside-a-cell sample, unedited:
<td>Apples</td>
<td><ul>
<li>Wash</li>
<li>Slice</li>
</ul></td>
This output is HTML inside a Markdown document. The destination must accept raw HTML for the retained structure to be useful. Disabling HTML forces a representation choice: move the list out of the table, flatten its text, or use a format that carries the structure. Pipe cells cannot hold ordinary block lists or paragraphs. 5
| Constraint | Visible result | Decision |
|---|---|---|
| The destination accepts raw HTML | Span attributes, breaks, and list elements remain in the saved output | Retain the HTML table |
| The destination requires pipe syntax | Merges need empty positions or rewritten labels | Define the field mapping before export |
| The cell contains code with a literal pipe | Our pipe-table output split the expression and lost the following link on readback | Escape the data pipe and read the result again |
We reran the original merged-table input and forced-pipe command from a Pandoc issue. The installed version produced a flat approximation instead of the placeholder reported there. That approximation kept the later cells in position, but it still replaced the merge with an empty field. 78
Repair field meaning before accepting a rendered table
A successful render proves that the reader recognized a table; the headings and cell content still need to describe the source records.
For the spanning-category example, an explicit rewrite can repeat East beside each item. The category then travels with the record if the rows are separated. An empty category position can retain the visual grouping in a nearby table, but a detached row would lack its category. Editorial rewrite, not converter output:
| Group | Item | Qty |
| --- | --- | --- |
| East | Apples | 12 |
| East | Pears | 9 |
Combine the parent and leaf labels, such as Quarter / Units and Quarter / Returns, into distinct flat fields. Leaving an unnamed column beside the parent label does not tell a reader which measurement it contains. Keep the original HTML if the hierarchy itself must remain visible. 2
Both converters emitted an unescaped pipe inside an inline code expression. The table readers split that expression into separate cells, then discarded the link beyond the width declared by the header. The saved Markdown still contained the link, while the parsed table did not. 8
Output from our inline-code sample, unedited:
| Expression | Reference |
| --- | --- |
| `a|b` | [docs](https://example.com/docs) |
Escape the literal pipe inside that cell as a\|b, keeping the code-span backticks around it. Our edited input restored the code expression and its neighboring link in both table readers. Apply the escape to a data pipe, not every pipe in the document: the others delimit cells. 8
markdown-it-py's table rule separates cells before parsing their inline content. It recognizes an escaped pipe during that split, then parses the code and link within the resulting cells. The header fixes the output width, so an extra apparent cell can disappear without a table-level error. 9
| Symptom | Specific discriminator | Action |
|---|---|---|
| The preview shows pipes as text | The parsed output has a paragraph and no table element | Enable table support or choose a representation the destination accepts |
| A table renders with fields shifted left | Compare the first record after a spanning cell with its headings | Restore the occupied position or retain HTML |
| The Markdown contains a link that the table omits | Inspect data pipes inside code before the missing link | Escape the literal pipe and render again |
| A list becomes bullet characters in running text | The output has cell text but no list elements | Keep HTML or move the list outside the table |
The ordinary grid appeared as a paragraph in our CommonMark-only reader and as a table after enabling its table rule. The merged-category and inline-code failures remained visible in the readers that did support tables. 8
These checks also apply when cleaning exported Markdown after Markovo's public-page conversion. That upstream route is listed as Signed-in Beta for publicly accessible HTTPS pages. We did not test the service on this table-fragment corpus. 10
Use flat Markdown fields for records whose relationships are explicit. Preserve HTML when spans or internal blocks carry meaning. When HTML is unavailable, rewrite those relationships into headings and repeated values before exporting, then inspect the first affected record and any cell containing a data pipe.
How we tested
This guide tests local conversion and parsing behavior on synthetic fragments, not GitHub.com, VS Code, browser layout, CSS fidelity, accessibility, or live webpage extraction. Markovo did not run this table-fragment corpus. Upstream PDF and Office results were excluded. No OCR scoring or performance benchmark was performed.
The conversion and readback date was 2026-10-20. We used markdownify 1.2.3 with Beautiful Soup 4.15.0, Pandoc 3.11, and markdown-it-py 4.0.0. Both Python environments used Python 3.11.14. 8
The reproduction script contains the complete inputs and commands. HTML inputs and raw HTML outputs are stored as .txt for publication. 11
| Input file | Represents |
|---|---|
| simple.input.txt | Rectangular table with explicit header |
| headerless.input.txt | Body rows without header cells |
| colspan.input.txt | Grouped header spanning two leaf columns |
| rowspan-left.input.txt | Leftmost category spans rows, missing placeholder shifts data |
| inline.input.txt | Inline code with a pipe and a link |
| break.input.txt | Explicit line break in a cell |
| list.input.txt | Block list inside a cell |
| issue-11128.input.txt | Pandoc issue 11128 source table, with span attributes |
| issue-121.input.txt | Table from markdownify issue 121, span is at right |
Run from the repository root with the existing environments. The script makes no network calls:
.venv/bin/python runs/2026-10-20-markovo-html-table-to-markdown/lab/table_roundtrip.py
The Python converter uses tools/lab-venv/bin/python, bs4_options="html.parser", and table_infer_header=False. The article's Python example is saved verbatim in convert_example.py. Both article commands were executed from the run's lab/example/ directory with the simple input copied to input.html. Their outputs and exit codes are retained in the execution record. 14
cd runs/2026-10-20-markovo-html-table-to-markdown/lab/example
cp ../renderers/simple.input.txt input.html
../../../../tools/lab-venv/bin/python ../convert_example.py
pandoc -f html -t gfm --wrap=none input.html -o output.md
The corpus command converts each input with markdownify and with pandoc -f html -t gfm --wrap=none. It reads each resulting Markdown file through MarkdownIt("commonmark", {"html": True}), the same reader with .enable("table"), and pandoc -f gfm -t html --wrap=none. The first reader is a control without the table rule. 811
“Tables CM/T/P” counts <table> elements in CommonMark-only markdown-it-py, markdown-it-py with its table rule, and Pandoc GFM readback, respectively. The remaining columns are tag counts from the markdown-it-py table-enabled readback: rows (tr), heading cells (th), data cells (td), breaks (br), and list items (li). These are physical elements, not a score for correct field associations. A raw HTML rowspan can occupy multiple slots while still being a single td. Each result also stores the cell text and attributes. 8
| Input | Converter | Tables CM/T/P | tr | th | td | br | li |
|---|---|---|---|---|---|---|---|
| simple | markdownify | 0/1/1 | 3 | 3 | 6 | 0 | 0 |
| simple | pandoc-gfm | 0/1/1 | 3 | 3 | 6 | 0 | 0 |
| headerless | markdownify | 0/1/1 | 3 | 3 | 6 | 0 | 0 |
| headerless | pandoc-gfm | 0/1/1 | 3 | 3 | 6 | 0 | 0 |
| colspan | markdownify | 0/1/1 | 3 | 3 | 6 | 0 | 0 |
| colspan | pandoc-gfm | 1/1/1 | 3 | 4 | 3 | 0 | 0 |
| rowspan-left | markdownify | 0/1/1 | 3 | 3 | 6 | 0 | 0 |
| rowspan-left | pandoc-gfm | 1/1/1 | 3 | 3 | 5 | 0 | 0 |
| inline | markdownify | 0/1/1 | 2 | 2 | 2 | 0 | 0 |
| inline | pandoc-gfm | 0/1/1 | 2 | 2 | 2 | 0 | 0 |
| break | markdownify | 0/1/1 | 2 | 2 | 2 | 0 | 0 |
| break | pandoc-gfm | 1/1/1 | 2 | 2 | 2 | 1 | 0 |
| list | markdownify | 0/1/1 | 2 | 2 | 2 | 0 | 0 |
| list | pandoc-gfm | 1/1/1 | 2 | 2 | 2 | 0 | 2 |
| issue-11128 | markdownify | 0/1/1 | 3 | 3 | 6 | 0 | 0 |
| issue-11128 | pandoc-gfm | 1/1/1 | 3 | 2 | 5 | 0 | 0 |
| issue-121 | markdownify | 0/1/1 | 3 | 3 | 6 | 0 | 0 |
| issue-121 | pandoc-gfm | 1/1/1 | 3 | 3 | 5 | 0 | 0 |
Counts alone miss the inline-code failure: both converters' inline output produced no code or a element in either table-enabled reader. The edited inline-repaired.md produced a code element and a link in each reader. The edited rowspan-repaired.md kept the category beside each item. These edited files are labeled editorial-rewrite in the report. They are not converter outputs. 8
The exact table from markdownify issue 121 was rerun with the installed version. Its short final row remained. The leftmost-span sample is a separate diagnostic variation, not the issue author's original input. Pandoc issue 11128 was also rerun with its reported writer configuration: 67
pandoc -f html -t markdown_strict+pipe_tables-raw_html --wrap=none \
runs/2026-10-20-markovo-html-table-to-markdown/lab/renderers/issue-11128.input.txt
The saved forced-pipe output contains an empty field before Theory. The installed Pandoc version did not reproduce the reported [TABLE] placeholder on that input. 78
The installed markdownify source records convert_tr, convert_td, convert_th, and convert_br. The installed parser source records escapedSplit and table. The former handles spans within a row without carrying rowspan occupancy forward. The latter fixes the column count from the header, fills absent cells with empty content, and splits cells before inline parsing. 1213
Original conversion files follow lab/renderers/<sample>--markdownify.md and <sample>--pandoc-gfm.md. Raw readbacks follow <sample>--<converter>--<reader>.txt. The report records conversion commands and Markdown output hashes, plus readback paths, cell text, and attributes in results.json. The summary does not use tools/lab_score.py: that script defines PDF extraction metrics, while this experiment measures Markdown/HTML structure. 811
Sources
- GFM specification: tables extension
- HTML Standard: table processing model
- markdownify README, version 1.2.3
- markdownify source, version 1.2.3
- Pandoc manual: pipe tables
- markdownify issue 121: rowspan support
- Pandoc issue 11128: HTML colspan conversion
- Local conversion and renderer results
- markdown-it-py table rule, version 4.0.0
- Markovo product-scope snapshot
- Reproduction script and complete synthetic inputs
- Installed markdownify table-handling source
- Installed markdown-it-py table parser source
- Executed article examples


