Here is what came out the far end of an extractor.
Monday11:00-15:0014:30Lunch only
TuesdayClosedn/aRegular holiday
Closing time and last order fused into 15:0014:30. On the source page this was an ordinary bordered table. It looked fine in the browser, and the accessibility audit came back with zero violations. Reduce the same HTML to text, though, and the values glue themselves together.
I assumed a library bug. Then I changed the markup and measured again, and the markup turned out to be what decided the outcome. So I built the identical dataset four different ways and pushed all four through accessibility tooling and extractors side by side. Every number below is real output from that sandbox.
A table has three readers, not one
When you write table markup you usually picture one reader: a person looking at the grid. There are three.
The first is the accessibility tree. Browsers don't hand the DOM straight to assistive technology. They build a separate tree of roles, names and states and expose that to screen readers. For a table, the thing that tree has to carry isn't the cell values, it's the association between cells and headers. "14:30" on its own means nothing. It becomes information only when it arrives attached to the Monday row and the Last order column. The W3C WAI tables tutorial says so directly (Tables Tutorial):
Data tables are used to organize data with a logical relationship in grids. Accessible tables need HTML markup that indicates header cells and data cells and defines their relationship. Assistive technologies use this information to provide context to users.
The same document adds one more line: "Relying on visual cues alone is not sufficient to create an accessible table." Bold text on a grey background is a header to human eyes only.
The second reader is the search crawler, which parses HTML directly and is therefore comparatively forgiving.
The third has gained a lot of weight in the past few years: pipelines that reduce HTML to text or Markdown. Tools that summarize or quote a page, RAG indexers, content collectors of every kind, they mostly don't work on raw HTML. They isolate the main content, strip tags, produce plain text or Markdown, and use that. What survives that reduction and what evaporates is decided entirely by your markup and the converter's settings. The W3C page drops a line about exactly this, almost in passing: "Tables markup is often lost when converting from one format to another, though some programs may provide functionality to assist converting table markup."
The important part is that these three fail under different conditions. The accessibility tree lives on the presence of header cells. The extraction pipeline lives on the <table> element itself. Which means markup that satisfies one and not the other can exist. That was the premise worth testing.
One dataset, four markups
I built a seven-row, four-column opening-hours table. Columns: Day, Hours, Last order, Note. Rows: Monday through Sunday. The values are byte-identical across all four versions, as are the surrounding paragraphs and the CSS. Only the elements differ.
A. The full semantic table.
<table>
<caption>Opening hours</caption>
<thead><tr><th scope="col">Day</th><th scope="col">Hours</th>…</tr></thead>
<tbody>
<tr><th scope="row">Monday</th><td>11:00-15:00</td><td>14:30</td>…</tr>
</tbody>
</table>
B. A table with no header cells. No <caption>, no <thead>, every cell a <td>. This is the version you meet most often in real codebases. Tables pasted out of an admin screen or emitted by a CMS editor usually look like this.
<table>
<tr><td>Day</td><td>Hours</td><td>Last order</td><td>Note</td></tr>
<tr><td>Monday</td><td>11:00-15:00</td><td>14:30</td><td>Lunch only</td></tr>
</table>
C. A div grid with ARIA roles. Cells drawn with CSS Grid, then role="table", role="row", role="columnheader", role="rowheader" and role="cell" spelled out. Design systems that take accessibility seriously produce this shape all the time.
D. A div grid with nothing. A table to the eye and nowhere else.
Tooling: Node 22.22.0, axe-core 4.12.1 on jsdom 29.1.1, turndown 7.2.4, turndown-plugin-gfm, html-to-text 10.0.0, @mozilla/readability 0.6.0.
I scored three things in the extracted text. Does one output line carry a single weekday plus that row's four values in their original order (row recovery, out of 7)? Do adjacent values stay separated by some delimiter (cell delimiting)? Does a column-header line appear before the first data value (header retention)? The "exactly one weekday" condition in the first test exists so that a table collapsed onto a single line doesn't count as a win.
axe waved all four through
The automated accessibility check ran first, with wcag2a, wcag2aa, wcag21a, wcag21aa and best-practice all enabled.
A: headerCellsInDOM=11 violations=region(moderate)
B: headerCellsInDOM= 0 violations=region(moderate)
C: headerCellsInDOM=11 violations=region(moderate)
D: headerCellsInDOM= 0 violations=region(moderate)
region is a best-practice rule about page content sitting outside landmarks. Nothing to do with tables. Table-related violations: zero across all four. B, with no header cells whatsoever, passes. So does D, a div grid with no roles at all.
That isn't a defect in axe. Its table rules are built to catch structural contradictions: a <th> inside a layout table, a headers attribute pointing at an id that doesn't exist. Deciding that "this pile of divs should have been a table" requires understanding what the content means, and that isn't a job a rule engine can do. I've counted the barriers that survive a green automated audit before, and this belongs to the same family. What's different here is that the thing being missed isn't only human usability.
Count header cells in the DOM, though, and the four split. A and C expose 11 (four column headers plus seven row headers); B and D expose zero. Only A and C can hand a cell-to-header relationship to assistive technology. Stop reading here and the conclusion looks simple: a div grid with proper ARIA roles is equivalent to a semantic table.
Five extractors reshuffle the ranking
Same four markups, now through the extraction pipelines.
Tabulated. The number is row recovery out of 7, D is cell delimiting, H is header retention.
| Extractor | A semantic table | B no th
|
C div + ARIA | D div only |
|---|---|---|---|---|
| turndown 7.2.4, defaults | 0/7 D+ H- | 0/7 D+ H- | 0/7 D+ H- | 0/7 D+ H- |
| turndown + GFM plugin | 7/7 D+ H+ | 0/7 D+ H- | 0/7 D+ H- | 0/7 D+ H- |
| html-to-text 10, defaults | 0/7 D- H- | 0/7 D- H- | 0/7 D+ H- | 0/7 D+ H- |
| html-to-text + dataTable | 7/7 D+ H+ | 7/7 D+ H+ | 0/7 D+ H- | 0/7 D+ H- |
| Readability textContent | 7/7 D- H+ | 7/7 D- H+ | 7/7 D- H+ | 7/7 D- H+ |
Three things flip here.
ARIA roles do nothing at this layer. C and D score identically in every single extractor. Markup that dutifully declares role="columnheader" and a pile of styled divs are indistinguishable once reduced to text, because extractors read tag names, not role attributes. In the accessibility tree C was A's equal. Here it gets treated as D.
Converter defaults wreck even a perfect semantic table. turndown out of the box scores A at 0/7. It has no table rule at all, so it unrolls the cells into a vertical list:
Opening hours
Day
Hours
Last order
Monday
11:00-15:00
Rows and columns gone, values left standing in a queue. Add the GFM plugin and A comes out as a clean Markdown table. B still scores 0/7 with the plugin installed. Open the output and you find the converter gave up and dumped the raw HTML:
<table><tbody><tr><td>Day</td><td>Hours</td>…</tr>…</table>
GFM's table syntax requires a heading row. A table with no <th> can't produce one, so the converter walks away. A <td>-only table therefore renders fine in the browser, passes axe, and turns into an untransformed blob on the most common HTML-to-Markdown path. The 15:0014:30 from the opening is the same species of failure: html-to-text's default treats a table as a block and puts no delimiter between cells.
One line of config brings B back. Pass { selector: 'table', format: 'dataTable' } to html-to-text and both A and B jump to 7/7, rendered as a fixed-width table with the header row intact. That card is only playable when you control the extraction side. The settings inside somebody else's pipeline, the one fetching your page, are not yours to set.
Readability's 7/7 was down to whitespace
One row in that table looks odd: Readability scores 7/7 on all four markups. My first read was that a content extractor holds structure better. It didn't sit right, though. textContent strips tags and concatenates text nodes. It has no mechanism for creating row boundaries.
So I added a second condition: the same HTML with newlines and indentation between tags removed, the way a minifier or a template engine that doesn't pretty-print would emit it.
=== inter-tag whitespace stripped ===
Readability 0.6 textContent 0/7 0/7 0/7 0/7
All four collapsed. The other four extractors returned byte-identical scores under both conditions. Readability's row recovery was never produced by the markup. It was an accident of newlines in the source file, and it disappears the moment your HTML ships on one line.
That was the most valuable moment in the experiment for me. Looking at the first table, I was one step away from a wrong conclusion. Without that second condition I'd have written that textContent-based extraction preserves tables too. Whether a measurement comes from the thing you're testing or from an incidental condition only becomes visible when you shake the incidental condition.
The measurement code had a bug of its own. My first scoring function matched strings case-sensitively, and html-to-text uppercases <th> content by default. A was recovering perfectly and scoring 0/7 because of it. The numbers above only appeared after I switched to case-insensitive matching. Worth remembering that the extractor itself mutates values: if your headers contain proper nouns, that mutation flows straight downstream.
The layer role="table" can't reach
The four report cards line up like this.
| Markup | Cell-to-header relation in the a11y tree | Text extraction |
|---|---|---|
A <table> + <th scope>
|
present | survives |
B <table> with only <td>
|
absent | mostly broken |
C <div role="table">
|
present | entirely broken |
D <div> only |
absent | entirely broken |
One markup passes both, and no automated audit reports a single cell of that table.
The practical call I take from it: building a data grid out of divs and restoring meaning with ARIA holds up on accessibility alone, but it is a clear downgrade across machine readability as a whole. ARIA is a corrective aimed at exactly one consumer, the accessibility tree. The table element serves that consumer plus a wider set at the same time. When two approaches reach the same accessibility outcome, take the one that loses less on the side. That's not a hard call.
It also runs in the same direction as a principle W3C wrote down long ago, the first rule in Using ARIA:
If you can use a native HTML element or attribute with the semantics and behavior you require already built in, instead of re-purposing an element and adding an ARIA role, state or property to make it accessible, then do so.
That rule normally gets cited on accessibility grounds alone. This measurement adds a second one. Use the native element and the non-accessibility consumers come along free. Imitate the semantics with ARIA and those semantics never leave the accessibility tree.
I've made the same argument at a different layer before. Injecting LocalBusiness JSON-LD with JavaScript left the raw HTML showing zero structured-data blocks: correct in the browser, absent at the moment a machine carried the page away. Table markup has the identical shape. What you verified on screen and what the machine took home are two different results.
What this experiment does not claim
Drawing the lines honestly.
I measured open-source libraries. How real AI crawlers such as GPTBot or ClaudeBot ingest pages internally is not published, and nothing here establishes their behavior. Read these numbers as a reference point that leans on how common the HTML-to-text reduction is. They are not official figures.
I'm also not claiming that fixing table markup lifts rankings or AI citations. Google's official position on structured data captures the character of this problem well (structured data general guidelines):
Google does not guarantee that your structured data will show up in search results, even if your page is marked up correctly according to the Rich Results Test.
Markup opens a possibility; it doesn't buy an outcome. This work has the same character. It removes a failure mode. It doesn't purchase a result.
And the environment is jsdom, not a browser. I did not read these tables with an actual screen reader; I counted, at the DOM level, whether header cells are exposed to the accessibility tree. axe-core is one rule set among several, and another engine could return a different verdict.
Six things to check before you touch a table
Only what maps directly onto code.
-
Build data grids with
<table>.<div role="table">satisfies exactly one reader and loses all the others. If visual design constraints pushed you to divs, today'sdisplay: gridplusdisplay: contentscovers most of those layouts while keeping the<table>intact. -
Hunt down every
<table>with no<th>. That's variant B: the most common one, silent under axe, and broken wholesale in Markdown conversion. Worth a single grep across the codebase. -
Add
scopeand<caption>.scope="col"on column headers,<th scope="row">for row headers.<caption>records what the table is about as text, and it survives extraction intact. -
If you run an extraction pipeline, start with its config. turndown flattens tables without the GFM plugin; html-to-text glues cells together without
format: 'dataTable'. Don't assume the defaults are safe. - Don't trust textContent-based extraction. If rows appear to survive, that may be your source newlines talking. Run it once more against minified HTML and see whether the result holds.
-
Add one CI rule. "Every
<table>has at least one<th>and either a<caption>or anaria-label" is decidable by static analysis. That single line covers ground automated accessibility checks leave open.
Auditing table, form and landing markup against both the accessibility bar and the machine-readability bar is work I take on. If a site you run needs a pass at that standard, the contact route on my profile is open.

Top comments (0)