A table is the most liftable thing on a page and the most fragile. When it’s real markup, an extractor can reproduce it as a table and quote a single row. When it’s twelve <div>s styled into columns, the same content arrives as a run of unlabelled words, and nothing downstream can tell that the third word was a price and the fourth was a limit.
Even a well-built table usually fails for a second reason: it gets lifted without the sentence that said what it compares. Both problems are cheap to fix and neither is a writing problem. This is the markup check, the context fix, and the two smaller faults that travel with them.
Worth knowing before you scope it: the markup half is a template fix, not a content fix. Tables are almost always generated by one component, so a single developer ticket usually corrects every page that uses it at once. That is the rare AI-search item where the effort estimate is one ticket and the coverage is the whole site — which is the shape of request that gets scheduled.
Key Takeaways
- A table survives as a
<table>with<th>header cells, or it doesn’t survive as a table at all. Divs styled into a grid carry no relationship a parser can recover.- The more common failure is context: a table lifted on its own is a grid of values with no subject, because the sentence naming what it compares stayed behind.
- Units that live only in a column header get separated from their values when a table is flattened into prose.
- Lists have the same two failure modes — real
<ul>/<ol>markup, and an introducing sentence close enough to travel with them.
What happens to a table between your HTML and an answer
A table reaches a model as text, and the format that text usually takes is markdown.
That’s the convention across the parsing tools that publish their behaviour. Microsoft’s MarkItDown converts files “to Markdown for use with LLMs and related text analysis pipelines,” targeting markdown because it “still provides a way to represent important document structure” — explicitly “including: headings, lists, tables, links, etc.” (Microsoft, retrieved 2026-08-02). Azure AI Search’s Document Layout skill does the same on the retrieval side: its layout model “articulates content structure in JSON using Markdown syntax” (Microsoft Learn, retrieved 2026-08-02).
Two consequences follow. A real <table> comes through as a markdown table with its header row intact, which is close to lossless. Everything markdown can’t express is dropped on the way: column alignment, merged cells, and any relationship you communicated with CSS rather than markup. The test isn’t whether your table looks like a table — it’s whether the relationships in it exist in the HTML.
A grid of divs is not a table, and the spec says so
The HTML specification treats layout-versus-data as a correctness question, not a style preference, and it gives extraction as the reason.
“Tables must not be used as layout aids,” the spec states. “Historically, some web authors have misused tables in HTML as a way to control their page layout. This usage is non-conforming, because tools attempting to extract tabular data from such documents would obtain very confusing results” (WHATWG, retrieved 2026-08-02).
The failure that matters now is the mirror image. Almost nobody builds layouts from <table> any more, but plenty of teams build tables from CSS grid, because a div grid is easier to make responsive. The spec has nothing to say about that — it isn’t non-conforming — and the extraction cost is the same one in reverse: tabular data with no table semantics gives a parser no rows, no columns, and no header association to recover.
Step 1: Make the visual table an actual table
By the end of this step every table on the page is a <table> whose header cells are <th>.
Open the page, right-click a header cell, and inspect it. You’re looking for three things: a <table> element, a <thead> or at least a header row, and <th> rather than <td> for the cells that label a column or a row. The W3C’s tables tutorial states the requirement plainly — “header cells must be marked up with <th>, and data cells with <td>” — and the reason: “with structural markup, headers and data cells can be programmatically determined by software” (W3C WAI, retrieved 2026-08-02).
Two-column key/value tables are where this quietly goes wrong, because the label column looks like content rather than like a header. A row whose first cell reads “macOS” and whose second reads “13 Ventura or newer” needs <th scope="row"> on the first cell. For anything more complicated than one header row and one header column, the same tutorial notes that “explicit associations may be needed using scope, id, and headers attributes.”
Verification: every cell that labels a row or a column is a <th>, and the table is a <table>.
Step 2: Say what the table compares, immediately before it
By the end of this step each table is preceded by a sentence that names its subject.
This is the fix with the most value in it, and the one most often skipped. Retrieval splits a page into passages of a few hundred words and can surface one without its neighbours — chunking covers what that does to prose — so a table lifted on its own is a grid of numbers whose subject stayed two paragraphs up. Columns headed “Standard,” “Pro,” and “Enterprise” say nothing about what is being compared, priced, or limited.
The markup answer is <caption>, which the spec defines as representing “the title of the table that is its parent.” Use it, but don’t rely on it alone: markdown’s table syntax has no way to express one, which is why all ten tables on this blog render without a caption. The durable fix is a sentence of body text directly above the table naming the subject and the unit — prose survives every conversion path there is.
Verification: reading only the sentence above a table and the table itself, you can say what it compares and in what units.
Step 3: Keep units and labels inside the cells
By the end of this step no value in the table depends on a header you can’t see from the cell.
Units that live only in a column header get separated from their values whenever a table is flattened into prose rather than into a markdown grid. A “Price” column of bare numbers becomes a list of integers; a “Latency” column loses whether it was milliseconds or seconds. The fix is unglamorous: put the unit in the header text where it can’t be mistaken for a label — “Latency (ms)” rather than “Latency” with the unit explained underneath.
Footnotes are the related trap. A dagger pointing at a note below the table references something that may not travel, and the note itself is usually a fragment with no subject. If a qualification changes what a number means, it belongs in the cell or in the lead-in sentence.
Verification: each cell read on its own carries its unit and enough of its row label to mean something.
Step 4: Make lists real lists
By the end of this step every visual list is a <ul> or an <ol>, and it’s introduced by text next to it.
The markup half is the same argument as tables: a list built from <br> tags, or from paragraphs each beginning with a dash, has no list semantics, so nothing downstream can tell where one item ends. Ordered versus unordered carries meaning too — steps in a <ul> say the order doesn’t matter, which for a procedure is wrong.
The context half gets skipped more often, because a list feels self-explanatory in position. One sentence immediately above it fixes that, and writing the sentence is a useful test: if you can’t, the list is probably a group of unrelated items sharing a bullet style.
Verification: every list is <ul> or <ol>, ordered where sequence matters, with an introducing sentence directly above it.
Before and after
The before is a responsive pricing grid, which is how most of these are built now:
<div class="grid">
<div class="col"><span>Standard</span><span>50</span><span>5</span></div>
<div class="col"><span>Pro</span><span>500</span><span>25</span></div>
</div>
Flattened, that’s “Standard 50 5 Pro 500 25” — no header association, no units, no subject. The rewrite:
<p>Monthly request limits and seat counts by plan:</p>
<table>
<caption>Plan limits</caption>
<thead>
<tr><th>Plan</th><th>Requests (thousands/month)</th><th>Seats</th></tr>
</thead>
<tbody>
<tr><th scope="row">Standard</th><td>50</td><td>5</td></tr>
<tr><th scope="row">Pro</th><td>500</td><td>25</td></tr>
</tbody>
</table>
Nothing about the visible page has to change — a <table> can be styled as a grid. What changed is that the numbers have headers attached, the units are in the header text, the row labels are header cells, and a sentence outside the table says what the table is for.
Checking it across a site
Both halves of this are mechanical, so run them rather than reading for them.
Both checks are XPath expressions, which means your crawler runs them without a terminal. Add these as custom extraction rules and crawl:
count(//table[not(.//th)]) → tables with no header cell anywhere
name(//table/preceding-sibling::*[1]) → what sits immediately above each table
Filter the export to rows where the first column is greater than zero, and you have the markup failures. For the second, any row returning h2, h3, or an empty value is a table with no lead-in sentence — the fault step 2 describes. Both rules read the rendered DOM, so set the crawler to JavaScript rendering if your tables are built client-side; against raw HTML they will report a table that isn’t there yet as absent rather than as broken.
The same two checks from a terminal. Markup first, against rendered HTML:
find dist -name '*.html' -exec awk 'BEGIN{RS="</table>"}
/<table/ && !/<th[ >]/ {print FILENAME}' {} \; | sort -u
Across the 26 tables on this site it returns three, all in the docs, and all the same fault: two-column key/value tables whose label column is a <td> that should be a <th scope="row">. That’s the shape the check almost always takes — not one broken table, but one template.
Then context, against markdown sources:
awk 'FNR==1{prev=""} /^\|/{ if (!t) { if (prev ~ /^#/ || prev=="")
print FILENAME" line "FNR; t=1 } next } { t=0; if (NF) prev=$0 }' *.md *.mdx
Three of the eleven posts published here before this one trip it — a table sitting directly under its heading with no lead-in sentence. A heading is better than nothing, since it may travel under structure-aware splitting, but it isn’t the sentence step 2 asks for. No route here returns a verdict: none of them can tell a data table from a leftover layout table, and none can judge whether the sentence it found names the subject. For the diagnostic pass across all five common faults, see five checks for a page that ranks but never gets cited.
At scale this becomes a per-URL count. GEOBee checks lists and tables as one of the eight answer-readiness signals, with published weights; the finding worth acting on is a page whose content clearly warrants a table having none, or having one built from divs.
One limit is worth restating. A table carrying its own headers, units, and subject is one a machine can lift and reproduce without breaking. Whether anything chooses to lift it depends on competition, authority, freshness, and the question asked — and the format isn’t yours to request either: “Google systems determine whether a page would make a good featured snippet for a user’s search request, and if so, elevates it” (Google Search Central, retrieved 2026-08-02). Answer Engine Optimization: a page-level guide sets out the seven other signals this one sits alongside, the 40–60 word answer block is the same discipline applied to the passage rather than the table, and FAQ sections: leverage or filler asks which questions have earned a place at the foot of a page at all.