← All posts

How to Preserve Tables and Lists When Converting Pages to Markdown

Quick answer: To preserve tables and lists when converting a page to Markdown, use a converter that maps HTML <table> to GitHub Flavored Markdown (GFM) pipe tables and <ul>/<ol> to indented Markdown lists, instead of one that flattens everything to plain text. Naive text extraction concatenates cells into a single line and loses the row/column relationships that carry the meaning. The examples below show what breaks and how to keep the structure intact.

If you have ever converted a comparison table to text and gotten a wall of numbers with no idea which value belongs to which row, you have met this problem. The data was all there. The structure that made it readable was gone. For an LLM reading that text later, the loss is worse, because it has no way to reconstruct which price went with which plan.

Why naive HTML-to-text destroys structure

The simplest way to "convert" HTML is to grab the text nodes and drop the tags. Something like soup.get_text() in BeautifulSoup. It is fast, and for a paragraph of prose it is fine. For structured content it is a disaster.

Consider this table:

<table>
  <tr><th>Plan</th><th>Price</th><th>Requests</th></tr>
  <tr><td>Free</td><td>$0</td><td>500</td></tr>
  <tr><td>Pro</td><td>$19</td><td>3000</td></tr>
</table>

Run it through a text-only extractor and you get:

Plan Price Requests Free $0 500 Pro $19 3000

Every relationship is gone. Is $0 the price of Free or of Pro? A human can guess from context. A model consuming a thousand of these has no chance. The same collapse happens to lists: nested bullets flatten into one run-on line and the hierarchy disappears.

What a good converter does instead

A structure-aware converter walks the DOM and emits the Markdown construct that matches each element. Tables become pipe tables. Ordered and unordered lists become Markdown lists with real indentation. The output for the table above should look like this:

| Plan | Price | Requests |
| --- | --- | --- |
| Free | $0 | 500 |
| Pro | $19 | 3000 |

Now the columns are labeled and every value sits under its header. This renders as a table anywhere GFM is supported, and it reads as a table even as raw text, which is the part that matters for LLM consumption.

Here is the difference at a glance:

Approach Tables Nested lists LLM-readable
get_text() / plain strip Collapsed to one line Flattened, indent lost No
Basic html2text Usually kept Usually kept Mostly
GFM-aware converter Pipe tables Preserved indentation Yes

GFM tables in practice

The GitHub Flavored Markdown table syntax is the de facto standard, so target it. The rules are simple: a header row, a delimiter row of dashes, then data rows, all using pipes as column separators. A minimal converter for a single table looks like this:

from bs4 import BeautifulSoup

def table_to_gfm(table_html: str) -> str:
    soup = BeautifulSoup(table_html, "html.parser")
    rows = soup.find_all("tr")
    lines = []
    for i, row in enumerate(rows):
        cells = [c.get_text(strip=True) for c in row.find_all(["th", "td"])]
        lines.append("| " + " | ".join(cells) + " |")
        if i == 0:  # delimiter row after the header
            lines.append("| " + " | ".join(["---"] * len(cells)) + " |")
    return "\n".join(lines)

In production you would not write this yourself for every page. Established converters handle the edge cases. In Python, markdownify with GFM options; in JS, turndown plus the turndown-plugin-gfm extension. But knowing the target shape helps you debug when the output looks wrong.

Nested lists

Lists have the same failure mode. The structure lives in the indentation, and a naive extractor drops it. A good converter preserves depth by indenting each level:

- Fetch layer
  - Rendering
  - Proxy rotation
- Parse layer
  - Boilerplate removal
  - Markdown conversion

The two-space indent per level is what tells a reader (and a model) that "Rendering" is a child of "Fetch layer", not a sibling. Lose the indent and you lose the tree.

The gotchas that still bite

GFM tables are not as expressive as HTML tables, so some structures do not translate cleanly. Know these before they surprise you.

Gotcha What happens Practical fix
Merged cells (colspan/rowspan) GFM has no merge syntax Duplicate the value across cells or flatten to a list
Multi-line cells Newlines break the pipe row Replace inner newlines with <br> or a space
Pipes inside cell text Extra columns appear Escape as |
Deeply nested tables Tables inside cells GFM cannot nest; extract inner table separately
Empty header row Delimiter row misaligns Synthesize placeholder headers

Merged cells are the big one. A table with a header spanning three columns simply cannot be represented in GFM without a decision: repeat the value, or convert that section to a labeled list. There is no clean automatic answer, so pick a rule and apply it consistently.

Why structure matters for LLMs

This is not a cosmetic concern. When you feed web data to a model, the structure is signal. A pipe table lets the model associate "Pro" with "$19" with "3000 requests" as a single coherent record. A flattened blob forces the model to guess, and it will guess wrong often enough to poison downstream answers.

Preserved structure also chunks better. When you split content for retrieval, a clean table stays a self-contained unit instead of getting sliced across chunk boundaries mid-row. That feeds directly into retrieval quality. If you want the full picture on the conversion step, we cover it in depth in HTML to Markdown for LLMs, and the token-efficiency angle in token optimization for feeding web data to LLMs.

Skip the plumbing

You can wire up markdownify or turndown yourself, and for a fixed set of pages that is reasonable. The cost shows up when pages vary: one site nests tables, another ships colspan, a third renders the table client-side so your extractor sees an empty shell. A fetch API that returns clean Markdown handles the rendering and the conversion rules for you:

curl https://link.sc/v1/fetch \
  -H "Authorization: Bearer lsc_..." \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/pricing", "format": "markdown"}'

The response comes back as GFM with tables and lists intact, ready to store, chunk, or hand to a model. Whichever path you choose, the rule stands: convert to constructs that keep the relationships, not to a stream of stripped text.


Want Markdown that keeps its tables and lists from any URL? Try link.sc free.