Data Table Serialization: Engineering HTML for LLM Ingestion
AI crawlers read raw HTML with no CSS and no JavaScript. A CSS Grid 'table' serialises to an undifferentiated list of strings, and every row-column relationship in your data is destroyed before the model sees it.
Data Table Serialization: Engineering HTML for LLM Ingestion
A comparison table is one of the most citable assets a technical site can publish. Answer engines quote them constantly, because a structured comparison is precisely the shape of thing that answers a question.
Whether yours gets quoted depends on something most teams never check: what your table looks like as a flat string with all CSS removed.
The pipeline that decides this
An AI crawler fetches your page and gets raw HTML. No CSS — it did not request the stylesheet, and would not apply it if it had. No JavaScript. Just markup.
That markup is then flattened toward text for embedding and retrieval. Different systems do this differently and none publish their exact pipeline, but the shared constraint is unavoidable: structure survives only where it is encoded in the markup. Structure expressed in CSS does not exist at this stage.
This makes the test concrete. Strip the tags. Read what remains. If you cannot reconstruct the data from that string, neither can a model.
What a CSS Grid table serialises to
Here is the pattern that looks correct in a browser:
<div class="comparison-grid">
<div class="cell header">Browser</div>
<div class="cell header">animation-timeline</div>
<div class="cell header">text-wrap: pretty</div>
<div class="cell">Chrome</div>
<div class="cell">115</div>
<div class="cell">117</div>
<div class="cell">Safari</div>
<div class="cell">26</div>
<div class="cell">26</div>
</div>
.comparison-grid { display: grid; grid-template-columns: repeat(4, 1fr); }
Renders perfectly. Now remove the CSS:
Browser animation-timeline text-wrap: pretty Chrome 115 117 Safari 26 26
Every relationship is gone. Nothing states that 115 belongs to Chrome, or that it is the animation-timeline version rather than the text-wrap one. Worse, Chrome 115 117 reads naturally as a version range. A model can guess from the three-item repetition — and guessing is exactly the failure mode. Reorder the source for a visual reason, add a responsive variant that changes DOM order, insert an empty cell for spacing, and the guess becomes wrong.
The grid is not a table. It is a list of strings that a stylesheet arranges into a table shape for sighted users on a device that loaded the stylesheet.
What a semantic table serialises to
<table>
<caption>First stable browser version supporting each CSS feature. Verified against MDN, 25 July 2026.</caption>
<thead>
<tr>
<th scope="col">Browser</th>
<th scope="col">animation-timeline</th>
<th scope="col">text-wrap: pretty</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Chrome</th>
<td>115</td>
<td>117</td>
</tr>
<tr>
<th scope="row">Safari</th>
<td>26</td>
<td>26</td>
</tr>
<tr>
<th scope="row">Firefox</th>
<td>Behind flag</td>
<td>Not supported</td>
</tr>
</tbody>
</table>
The element names carry the semantics. <thead> marks which row defines the columns. <tr> groups a record. scope="col" and scope="row" state which axis each header governs. <caption> names what the table is about — and it is machine-readable in a way an adjacent <h3> is not, because <caption> is structurally bound to the table while a heading is merely nearby.
Any parser that understands HTML — every one of them — reconstructs the full grid from this. The relationships are in the markup, not in a stylesheet the crawler never fetched.
colspan and the merged-cell problem
Merged headers are where otherwise-correct tables break down.
<!-- Ambiguous: which columns does each group cover? -->
<tr>
<th></th>
<th colspan="2">Scroll-driven animations</th>
<th colspan="2">Typography</th>
</tr>
<tr>
<th>Browser</th>
<th>scroll()</th><th>view()</th>
<th>balance</th><th>pretty</th>
</tr>
A browser renders the spans correctly. A flattening parser encounters Scroll-driven animations spanning two columns and must infer which two, then associate the second header row beneath it, then associate the data rows beneath that. Three inference steps, each a chance to get it wrong.
headers and id remove the inference entirely:
<table>
<caption>CSS feature support by browser. Verified against MDN, 25 July 2026.</caption>
<thead>
<tr>
<td></td>
<th id="sda" colspan="2" scope="colgroup">Scroll-driven animations</th>
<th id="typo" colspan="2" scope="colgroup">Typography</th>
</tr>
<tr>
<th id="br" scope="col">Browser</th>
<th id="scrollf" scope="col">scroll()</th>
<th id="viewf" scope="col">view()</th>
<th id="balance" scope="col">balance</th>
<th id="pretty" scope="col">pretty</th>
</tr>
</thead>
<tbody>
<tr>
<th id="chrome" scope="row">Chrome</th>
<td headers="chrome sda scrollf">115</td>
<td headers="chrome sda viewf">115</td>
<td headers="chrome typo balance">114</td>
<td headers="chrome typo pretty">117</td>
</tr>
</tbody>
</table>
Verbose, and worth it for complex tables. Every cell now declares its full coordinate set explicitly. headers="chrome sda scrollf" states unambiguously: this value belongs to Chrome, under scroll-driven animations, for the scroll() function. There is nothing left to infer.
The same markup drives correct screen-reader announcement. This is not a coincidence — assistive technology and answer engines both consume the accessibility-relevant structure of the document, so the accessible table and the extractable table are the same artifact. Building for one gets you the other.
Units belong in the header
A common extraction failure:
<!-- The unit is in the cell, repeated, and inconsistently formatted -->
<td>250 ms</td>
<td>1.2 s</td>
<td>800ms</td>
Three formats for one dimension. A model extracting these must normalise ms, s, and ms-without-a-space, and work out whether 1.2 s is larger than 800ms.
Put the unit in the header, keep cells numeric and consistently formatted:
<th scope="col">Response time <span class="unit">(ms)</span></th>
...
<td>250</td>
<td>1200</td>
<td>800</td>
Now the column has one declared unit and the values are directly comparable. Same principle for currency, dates (use ISO 8601), and percentages.
For values that need both a machine form and a display form, <data>:
<td><data value="250">250 ms</data></td>
<td><data value="1200">1.2 s</data></td>
And for dates, <time>:
<td><time datetime="2026-03-15">15 March 2026</time></td>
Responsive tables without destroying the structure
The reason teams reach for CSS Grid is that tables are awkward on narrow screens. The fix does not require abandoning the element.
Horizontal scroll, correctly done:
<div class="table-scroll" tabindex="0" role="region" aria-labelledby="tbl-caption">
<table>
<caption id="tbl-caption">CSS feature support by browser</caption>
...
</table>
</div>
.table-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.table-scroll:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
tabindex="0" makes the scroll container keyboard-reachable — without it, keyboard users cannot scroll to see the hidden columns. This is a real and frequently missed accessibility bug.
Card layout on narrow screens, structure intact:
@media (max-width: 40rem) {
table, thead, tbody, tr, th, td { display: block; }
thead { position: absolute; inset-inline-start: -10000px; }
tr {
border: 1px solid var(--border);
margin-block-end: 1rem;
padding: 0.75rem;
}
td {
display: grid;
grid-template-columns: minmax(8rem, 40%) 1fr;
gap: 0.5rem;
}
td::before {
content: attr(data-label);
font-weight: 600;
}
}
<td data-label="animation-timeline">115</td>
The markup stays a table. display: block is a presentational override that CSS-less consumers never see. The data-label duplicates the header for visual users on mobile, while the real <th> still governs the semantic relationship.
One caveat worth knowing: overriding display on table elements removes them from the browser’s table accessibility mapping in some engines, so screen readers may lose row/column context at that breakpoint. If that trade matters for your audience, prefer the scroll container. It is the safer default.
Verifying your table extracts correctly
Do not assume. Test.
1. Read the flattened text.
curl -s https://yoursite.com/comparison \
| python3 -c "
import sys, re
h = sys.stdin.read()
t = re.search(r'<table.*?</table>', h, re.S)
print(re.sub(r'<[^>]+>', ' ', t.group()) if t else 'NO <table> FOUND')
"
If that prints NO <table> FOUND, you have a div grid.
2. Check the accessibility tree. DevTools → Elements → Accessibility. A correct table exposes role table with rowheader and columnheader descendants. A div grid exposes a stack of generics. What you see there closely tracks what a structure-aware parser recovers.
3. Test with a screen reader. VoiceOver (Cmd+F5) or NVDA. In table navigation mode, moving between cells should announce the relevant headers — “Chrome, animation-timeline, 115”. If it announces bare values with no context, the association is broken.
4. Ask a model directly. Paste the raw HTML of the table into ChatGPT or Claude and ask it to convert to CSV. Correct markup round-trips cleanly. A div grid produces a mangled result, and you will see precisely where the relationships broke.
Sortable tables without losing the source of truth
The other reason teams abandon <table> is interactivity — sorting, filtering, virtualised rendering. A JavaScript data-grid library renders rows into divs and the extractable structure disappears.
The resolution is to render the real table server-side and enhance it:
<table id="support" data-sortable>
<caption>CSS feature support — click a column header to sort</caption>
<thead>
<tr>
<th scope="col">
<button type="button" data-sort="browser" aria-describedby="sort-hint">
Browser
</button>
</th>
<th scope="col" aria-sort="ascending">
<button type="button" data-sort="version" data-type="number">
animation-timeline (version)
</button>
</th>
</tr>
</thead>
<tbody>
<tr><th scope="row">Chrome</th><td>115</td></tr>
<tr><th scope="row">Safari</th><td>26</td></tr>
</tbody>
</table>
<p id="sort-hint" class="visually-hidden">Sorts the table by this column.</p>
Sorting reorders <tr> elements inside the existing <tbody>. The markup a crawler receives is the complete, correctly structured table in its default order. The interactivity is genuinely additive: with JavaScript disabled, the table is fully readable and only the sort buttons are inert.
aria-sort on the active column is what tells assistive technology the current state. Update it on every sort — a sortable table without aria-sort announces nothing about what just happened.
Virtualisation is the case where this breaks. Rendering only visible rows means the crawler sees fifty rows of a ten-thousand-row dataset. If the full dataset matters for citation, publish it as a paginated set of real tables, or offer a downloadable CSV linked with Dataset markup, and treat the virtualised view as a convenience layer over a complete underlying resource.
Long tables: <tfoot> and row grouping
Two elements that are underused and cheap.
<tfoot> marks summary rows — totals, averages, aggregates — as structurally distinct from data rows:
<tbody>
<tr><th scope="row">Chrome</th><td>115</td></tr>
<tr><th scope="row">Edge</th><td>115</td></tr>
<tr><th scope="row">Opera</th><td>101</td></tr>
</tbody>
<tfoot>
<tr><th scope="row">Median</th><td>115</td></tr>
</tfoot>
Without <tfoot>, a median row is just another data row, and a model extracting “the browsers compared” will list Median as a browser. This is a real and common extraction error, and one element fixes it.
Multiple <tbody> elements group related rows, each with its own heading row:
<tbody>
<tr><th colspan="2" scope="colgroup">Chromium-based</th></tr>
<tr><th scope="row">Chrome</th><td>115</td></tr>
<tr><th scope="row">Edge</th><td>115</td></tr>
</tbody>
<tbody>
<tr><th colspan="2" scope="colgroup">Other engines</th></tr>
<tr><th scope="row">Safari</th><td>26</td></tr>
</tbody>
This is valid HTML — a table may contain multiple <tbody> elements — and it encodes a grouping that would otherwise exist only as a visual separator.
Structured data: useful, not a substitute
You can add JSON-LD alongside the table:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Dataset",
"name": "CSS scroll-driven animation and text-wrap support by browser",
"description": "First stable browser version supporting each CSS feature, compiled from vendor documentation.",
"creator": { "@type": "Organization", "name": "[YOUR ORG]" },
"dateModified": "2026-07-25",
"license": "https://creativecommons.org/licenses/by/4.0/",
"measurementTechnique": "Compiled from MDN Browser Compatibility Data and vendor release notes, verified 2026-07-25"
}
</script>
This helps with provenance and freshness signals. It does not replace the table markup. Duplicating an entire dataset into JSON-LD creates two sources of truth that drift apart on the first edit — and drift is worse than absence, because now the model has two conflicting versions of your data.
Mark up the table properly. Use JSON-LD to describe it.
Frequently asked questions
Can AI crawlers read CSS Grid tables? They read the text but not the structure. Without the stylesheet, a grid serialises to an undifferentiated list of strings with no row or column relationships. Models may guess from positional patterns, and the guess is unreliable.
Do I need scope attributes?
For simple tables with one header row and one header column, yes — they are cheap and remove ambiguity. For tables with merged or multi-level headers, scope alone is insufficient; use headers and id.
Is <caption> better than a heading above the table?
For machine parsing, yes. <caption> is structurally part of the table element, so any parser binds it to that table. A nearby <h3> is only spatially associated, and that association is inferred rather than declared.
Does JSON-LD replace semantic table markup? No. It adds provenance and description. Duplicating the data creates two sources of truth that will drift on the first edit, leaving the model with conflicting versions.
How do I make tables responsive without breaking semantics?
Prefer a focusable horizontal scroll container — overflow-x: auto with tabindex="0". The card-layout approach with display: block works visually but can remove table semantics from the accessibility tree in some browsers.
Should I use ARIA table roles on a div grid?
Only if you cannot change the markup. role="table" with role="row" and role="columnheader" restores accessibility-tree semantics, but the raw HTML still serialises poorly for text-extraction pipelines. Real <table> markup is better on both counts.
Where should units go? In the header, once, with cells kept numeric and consistently formatted. Mixed units within a column force normalisation and invite extraction errors.
How do I test what a model extracts? Paste the raw table HTML into an LLM and ask for CSV. Correct markup round-trips cleanly; a div grid produces a mangled result that shows you exactly where the structure was lost.
Sources
- HTML Living Standard §4.9 — Tabular data
- W3C WAI Tables Tutorial —
scope,headers, andid - MDN:
<table> - schema.org
Dataset - Browser version data in the examples: MDN Browser Compatibility Data, verified 25 July 2026
The example tables use real, verifiable browser support data rather than invented benchmarks — if you are going to publish an article about data integrity, the data in it should survive being checked.
Claims last verified 25 July 2026. See our editorial standards.