HTML Guides for th
Learn how to identify and fix common HTML validation errors flagged by the W3C Validator — so your pages are standards-compliant and render correctly across every browser. Also check our Accessibility Guides.
A colspan on a <th> has to say how many columns the header covers, as a whole number from 1 to 1000, so colspan="" is invalid.
Writing colspan with no value at all gives the same message. If the value holds only spaces, as in colspan=" ", the validator reports it differently, with a message that begins "Expected a digit".
Header cells often end up like this when a table plugin or theme writes the span attributes on every <th> and fills them from a setting that is blank for most columns. Check the same headers for rowspan="". The validator flags it separately, as "not a valid non-negative integer", because rowspan also accepts 0.
An empty colspan changes nothing on screen: browsers read it as 1, just as they do when the attribute is missing. Remove it from every header that sits over a single column, and keep a number only where a header groups several columns, such as colspan="2" above two related columns.
Invalid example
<table>
<tr>
<th colspan="">Course</th>
<th colspan="2">Schedule</th>
</tr>
<tr>
<td>Biology</td>
<td>Monday</td>
<td>Wednesday</td>
</tr>
</table>
Valid example
<table>
<tr>
<th>Course</th>
<th colspan="2">Schedule</th>
</tr>
<tr>
<td>Biology</td>
<td>Monday</td>
<td>Wednesday</td>
</tr>
</table>
A rowspan on a <th> sets how many rows the header cell covers, and it only accepts a whole number from 0 to 65534, so rowspan="" fails validation.
The validator reports a bare rowspan, written with no value, in exactly the same way. A value that holds only spaces, or a negative number such as -1, produces another message instead, one that begins with "Expected a digit".
The usual source is a header row generated by a table plugin or theme that prints rowspan and colspan on every <th> and leaves them blank when the cells aren't merged. Look at those headers for colspan="" as well. The validator flags it in a separate message, "not a valid positive integer", because colspan has to be at least 1.
An empty rowspan makes no visual difference: browsers read it as 1, the same value they use when the attribute is absent, so you can delete it safely. Avoid swapping in rowspan="0". That value passes validation, but it makes the header reach down to the last row of its <thead>, <tbody> or <tfoot>. Leave rowspan only on headers that span two or more rows, such as a row header that labels a group of rows.
Invalid example
<table>
<tr>
<th rowspan="">Region</th>
<th rowspan="">Store</th>
<th rowspan="">Sales</th>
</tr>
<tr>
<th rowspan="2">North</th>
<td>Leeds</td>
<td>1,200</td>
</tr>
<tr>
<td>York</td>
<td>950</td>
</tr>
</table>
Valid example
<table>
<tr>
<th>Region</th>
<th>Store</th>
<th>Sales</th>
</tr>
<tr>
<th rowspan="2">North</th>
<td>Leeds</td>
<td>1,200</td>
</tr>
<tr>
<td>York</td>
<td>950</td>
</tr>
</table>
The colspan attribute of a <th> takes a positive whole number written with digits only, and the validator names the first character in the value that isn't a digit.
colspan sets how many columns a header cell spans. The HTML standard requires a valid non-negative integer greater than zero and no larger than 1000, such as 2 or 3. Units, percentages, decimals, signs, spaces, quotes and keywords like auto all make the value invalid. The character after "saw" is where the number stopped: p in 2px, % in 100%, . in 1.5, - in -1.
A value that contains markup, like 2><span class=, usually means a quote is missing. In <th colspan="2><span class="label">, nothing closes the quote after 2, so the parser reads on to the next " and puts everything in between into colspan. The <span> start tag is lost, and what's left of it becomes stray attributes on the <th>. That's why errors like "No space between attributes" and "Quote in attribute name" usually appear next to this one. When the number is missing too, the value starts right at the >. A CMS or rich text editor can store the broken attribute with the < escaped as <, so check the saved content as well as the template.
Browsers read the leading digits and ignore the rest, so 2px still spans two columns. A value that doesn't start with a digit, or is zero, falls back to 1. The header then covers one column and every cell after it in the row moves over, so screen readers can announce data cells under the wrong header.
Write the number on its own, without a unit, and close the attribute's quote before the >.
Invalid example
<table>
<tr>
<th colspan="2px">Name</th>
<th colspan="2><span class="label">Contact</span></th>
</tr>
<tr>
<td>Ada</td>
<td>Lovelace</td>
<td>ada@example.com</td>
<td>555-0100</td>
</tr>
</table>
Valid example
<table>
<tr>
<th colspan="2">Name</th>
<th colspan="2"><span class="label">Contact</span></th>
</tr>
<tr>
<td>Ada</td>
<td>Lovelace</td>
<td>ada@example.com</td>
<td>555-0100</td>
</tr>
</table>
The HTML specification requires that table structures be coherent — every column in a table must be "used" by at least one cell that starts in it. When you declare a <th> with colspan="2", you're telling the browser that the table has (at least) two columns. If the body rows don't provide cells to fill those columns, the table's structure becomes inconsistent.
This matters for several reasons. Screen readers and other assistive technologies rely on a well-formed table grid to navigate and announce cell contents. An inconsistent column structure can confuse these tools, leading to a poor experience for users who depend on them. Browsers may also render such tables unpredictably, since they have to guess how to handle the mismatch.
Common causes of this error include:
- A
colspanvalue on a header cell that exceeds the actual number of cells in body rows. - Empty
<tbody>,<thead>, or<tfoot>sections that leave declared columns without any cells. - Rows with fewer cells than other rows define, leaving some columns unoccupied.
- Copy-paste errors or template issues where rows were deleted but headers were left unchanged.
To fix this, examine your table's column structure. Count the total number of columns implied by each row (accounting for colspan and rowspan attributes) and make sure every row agrees on the total column count. Every column should have at least one cell beginning in it somewhere in the table.
Examples
Incorrect: colspan creates columns with no matching body cells
Here, the header declares two columns, but the body is empty, so column 2 has no cells beginning in it:
<table>
<thead>
<tr>
<th colspan="2">The table header</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
Incorrect: Body rows have fewer cells than the header defines
The header spans three columns, but the body rows only have two cells each. Column 3 has no cells beginning in it:
<table>
<thead>
<tr>
<th colspan="3">Wide header</th>
</tr>
</thead>
<tbody>
<tr>
<td>Cell 1</td>
<td>Cell 2</td>
</tr>
</tbody>
</table>
Correct: Body cells match the columns defined by the header
The header spans two columns, and the body row provides exactly two cells:
<table>
<thead>
<tr>
<th colspan="2">The table header</th>
</tr>
</thead>
<tbody>
<tr>
<td>The table body</td>
<td>with two columns</td>
</tr>
</tbody>
</table>
Correct: Three-column header with matching body cells
<table>
<thead>
<tr>
<th>Name</th>
<th>Role</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>Alice</td>
<td>Developer</td>
<td>Active</td>
</tr>
</tbody>
</table>
Correct: Using colspan in body rows to fill all columns
If you need fewer visible cells in a body row, use colspan to span the remaining columns rather than leaving them empty:
<table>
<thead>
<tr>
<th>Name</th>
<th>Role</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>Alice</td>
<td colspan="2">Info unavailable</td>
</tr>
</tbody>
</table>
Correct: Reducing colspan to match actual content
If the header doesn't truly need to span multiple columns, simply remove the colspan:
<table>
<thead>
<tr>
<th>The table header</th>
</tr>
</thead>
<tbody>
<tr>
<td>The table body</td>
</tr>
</tbody>
</table>
A <th> with a colspan wider than the rest of the table adds the columns in the reported range, and no <td> or <th> begins in any of them.
The validator builds the table grid from each row's cells and their colspan values. A <th colspan="4"> that starts in column 2 covers columns 2 to 5, so the table has at least five columns. The HTML table model requires every column to have at least one cell that starts in it, and a cell that only crosses a column through colspan doesn't count. If the other rows have only two cells, nothing starts in columns 3 to 5, and the validator reports them as the range 3…5, naming th as the element that created them.
This often happens with header rows that group headings over more columns than the body has, or with a colspan left over from a wider version of the table. The short rows usually add a warning too: "A table row was 2 columns wide, which is less than the column count established by the first row (5)." When only one column is empty, the validator names that single column ("Table column 3") instead of a range.
To fix it, lower the colspan so the header covers only the columns the rows use. If the table really needs the extra columns, add cells to the other rows so a cell starts in each of them.
Invalid example
The second heading spans four columns, but each body row has only two cells:
<table>
<tr>
<th>Term</th>
<th colspan="4">Dates</th>
</tr>
<tr>
<td>Autumn</td>
<td>September to December</td>
</tr>
</table>
Valid example
Without the colspan, the header and the body both have two columns:
<table>
<tr>
<th>Term</th>
<th>Dates</th>
</tr>
<tr>
<td>Autumn</td>
<td>September to December</td>
</tr>
</table>
When you write a <table> without explicitly using <thead> and <tbody>, the HTML parser automatically wraps your <tr> elements in an implicit <tbody>. If any of those rows contain <th> elements intended as column headers, the validator flags them because <th> cells in <tbody> are unexpected — the parser sees header cells appearing in what should be the data body of the table.
While <th> elements are technically valid inside <tbody> (for example, as row headers), this warning usually indicates a structural problem: your column headers aren't properly separated from your data rows. Properly structuring your table with <thead> and <tbody> matters for several reasons:
- Accessibility: Screen readers use table structure to help users navigate. A
<thead>section clearly identifies column headers, making it easier for assistive technology to announce what each data cell represents. - Styling and behavior: CSS selectors like
thead thandtbody tdlet you target headers and data cells independently. Browsers can also use<thead>and<tbody>to enable scrollable table bodies while keeping headers fixed. - Standards compliance: Explicitly defining table sections removes ambiguity and ensures consistent parsing across all browsers.
To fix this issue, wrap the row containing your <th> column headers in a <thead> element, and wrap your data rows in a <tbody> element.
Examples
❌ Incorrect: <th> in implicit table body
Here, the parser wraps all rows in an implicit <tbody>, so the <th> elements end up inside the table body:
<table>
<tr>
<th>Name</th>
<th>Age</th>
</tr>
<tr>
<td>Liza</td>
<td>49</td>
</tr>
<tr>
<td>Joe</td>
<td>47</td>
</tr>
</table>
✅ Correct: <th> in explicit <thead>
Wrapping the header row in <thead> and data rows in <tbody> resolves the issue:
<table>
<thead>
<tr>
<th>Name</th>
<th>Age</th>
</tr>
</thead>
<tbody>
<tr>
<td>Liza</td>
<td>49</td>
</tr>
<tr>
<td>Joe</td>
<td>47</td>
</tr>
</tbody>
</table>
✅ Correct: <th> as row headers inside <tbody>
If you intentionally use <th> elements inside <tbody> as row headers, add the scope attribute to clarify their purpose. This is valid and won't trigger the warning when the table also has a proper <thead>:
<table>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Age</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Liza</th>
<td>49</td>
</tr>
<tr>
<th scope="row">Joe</th>
<td>47</td>
</tr>
</tbody>
</table>
The scope="row" attribute tells assistive technology that these <th> cells are headers for their respective rows, while scope="col" identifies column headers. This combination provides the best accessibility for table data.
A <th> at the top of a column is already exposed as a column header, so role="columnheader" on it tells screen readers nothing new.
Browsers work out the ARIA role of a <th> from its place in the table. A header cell in the <thead>, or in a row of headers above the data, gets the role columnheader. A header cell at the start of a data row gets rowheader. When the layout leaves room for doubt, scope="col" or scope="row" settles it. Writing the role by hand adds nothing and makes the markup harder to read.
The validator reports this warning for role="columnheader" on any <th>, wherever it sits. On a header that starts a row, the role is also wrong, because it overrides rowheader and tells screen readers the cell heads a column.
In most tables you'll see an error next to this warning. When the <table> has no role attribute, or has role="table", role="grid" or role="treegrid", its <th> cells can't have a role attribute at all. Removing role="columnheader" fixes both messages.
Table plugins and markup copied from online word processors often add a role to every part of a table. The columnheader role exists for tables built from generic elements such as <div>, where nothing else marks a cell as a header. A native <th> doesn't need it, so delete the attribute and keep scope if you want to state the direction. If the same table has roles on its rows or other cells, remove those as well.
Invalid example
<table>
<thead>
<tr>
<th scope="col" role="columnheader">Day</th>
<th scope="col" role="columnheader">Opening hours</th>
</tr>
</thead>
<tbody>
<tr>
<td>Monday</td>
<td>9:00 to 17:00</td>
</tr>
<tr>
<td>Saturday</td>
<td>10:00 to 14:00</td>
</tr>
</tbody>
</table>
Valid example
<table>
<thead>
<tr>
<th scope="col">Day</th>
<th scope="col">Opening hours</th>
</tr>
</thead>
<tbody>
<tr>
<td>Monday</td>
<td>9:00 to 17:00</td>
</tr>
<tr>
<td>Saturday</td>
<td>10:00 to 14:00</td>
</tr>
</tbody>
</table>
A th element can't contain a heading, from h1 to h6, at any depth. Put the text straight in the th, style it with CSS, or title the table with a caption or a heading before it.
The HTML standard defines the content of a th as flow content with no header, footer, sectioning content or heading content (h1 to h6 and hgroup) among its descendants. The rule applies at any depth, so a heading wrapped in a div inside the th still triggers this error. The message names the heading level the validator found, and the fix is the same for every level. A td doesn't have this restriction: headings are allowed in data cells.
A th is already a header. Screen readers announce it when they read the cells of its row or column, so a heading inside it adds nothing to the table. It does add an entry to the page's heading outline, and users who move through the page by headings land inside the table as if a new section started there.
Headings usually end up in header cells to make the text bigger or bolder. Browsers already show th text in bold, and CSS can change the size, weight or letter case.
How to fix it
- Remove the heading element and keep its text directly inside the
th. - Style the
thwith CSS if the header cells need a particular look. - Use a
captionif the heading was meant as a title for the whole table. Acaptionis tied to the table, and screen readers announce it with it. - Put a heading before the
tableif the table needs an entry in the page's outline, at a level that fits the surrounding headings.
Examples
❌ Invalid: heading inside a th
<table>
<tr>
<th><h2>Product</h2></th>
<th><h2>Price</h2></th>
</tr>
<tr>
<td>Widget</td>
<td>$25</td>
</tr>
</table>
Each h2 is a descendant of a th, which the th content model doesn't allow.
✅ Valid: plain text in the th, styled with CSS
<table class="price-table">
<tr>
<th>Product</th>
<th>Price</th>
</tr>
<tr>
<td>Widget</td>
<td>$25</td>
</tr>
</table>
.price-table th {
font-size: 1.2em;
text-transform: uppercase;
}
✅ Valid: a caption as the table title
<table>
<caption>Product pricing</caption>
<tr>
<th>Product</th>
<th>Price</th>
</tr>
<tr>
<td>Widget</td>
<td>$25</td>
</tr>
</table>
✅ Valid: a heading before the table
<h2>Product pricing</h2>
<table>
<tr>
<th>Product</th>
<th>Price</th>
</tr>
<tr>
<td>Widget</td>
<td>$25</td>
</tr>
</table>
The headers attribute creates explicit associations between data cells (td) and header cells (th) in complex tables. This is especially important for tables with irregular structures—such as those with merged cells or multiple header levels—where the browser cannot automatically determine which headers apply to which data cells.
When the validator reports this error, it means one or more IDs referenced in a td's headers attribute cannot be matched to any th element with that id in the same table. Common causes include:
- Typos — A small misspelling in either the
headersvalue or thethelement'sid. - Missing
id— Thethelement exists but doesn't have anidattribute assigned. - Removed or renamed headers — The
thwas deleted or itsidwas changed during refactoring, but thetdstill references the old value. - Cross-table references — The
thwith the referencedidexists in a different<table>, which is not allowed.
Why this matters
This issue directly impacts accessibility. Screen readers use the headers attribute to announce which header cells are associated with a data cell. When a referenced ID doesn't resolve to a th in the same table, assistive technology cannot provide this context, making the table confusing or unusable for users who rely on it. Broken headers references also indicate invalid HTML according to the WHATWG HTML specification, which requires that each token in the headers attribute match the id of a th cell in the same table.
How to fix it
- Locate the
tdelement flagged by the validator and note the ID it references. - Search the same
<table>for athelement with a matchingid. - If the
thexists but has noidor a differentid, add or correct theidattribute so it matches. - If the
thwas removed, either restore it or remove theheadersattribute from thetd. - Double-check for case sensitivity — HTML
idvalues are case-sensitive, soheaders="Name"does not matchid="name".
Examples
Incorrect: headers references a non-existent ID
The first td references "product", but no th has id="product". The second th has id="cost", but the second td references "price" — a mismatch.
<table>
<tr>
<th>Product</th>
<th id="cost">Price</th>
</tr>
<tr>
<td headers="product">Widget</td>
<td headers="price">$9.99</td>
</tr>
</table>
Correct: each headers value matches a th with the same id
<table>
<tr>
<th id="product">Product</th>
<th id="cost">Price</th>
</tr>
<tr>
<td headers="product">Widget</td>
<td headers="cost">$9.99</td>
</tr>
</table>
Correct: multiple headers on a single td
In complex tables, a data cell may relate to more than one header. List multiple IDs separated by spaces — each one must correspond to a th in the same table.
<table>
<tr>
<th id="region" rowspan="2">Region</th>
<th id="q1" colspan="2">Q1</th>
</tr>
<tr>
<th id="sales">Sales</th>
<th id="returns">Returns</th>
</tr>
<tr>
<td headers="region">North</td>
<td headers="q1 sales">1200</td>
<td headers="q1 returns">45</td>
</tr>
</table>
Tip: simple tables may not need headers at all
For straightforward tables with a single row of column headers, browsers and screen readers can infer the associations automatically. In those cases, you can omit the headers attribute entirely and avoid this class of error:
<table>
<tr>
<th>Product</th>
<th>Price</th>
</tr>
<tr>
<td>Widget</td>
<td>$9.99</td>
</tr>
</table>
Reserve the headers attribute for complex tables where automatic association is insufficient — such as tables with cells that span multiple rows or columns, or tables with headers in both rows and columns.
Every ID in the headers attribute of a th has to be the id of another th in the same table, and the validator flags each one that isn't.
On a header cell, headers ties a sub-header to the header cells above it or beside it. When a "Sales" header spans the "Q1" and "Q2" columns, <th headers="sales">Q1</th> says that Q1 belongs to Sales. The value is a space-separated list of IDs, and the validator checks each one on its own, so a single cell can trigger this message more than once.
The referenced header is usually where things went wrong. The validator reports the ID when:
- the group header has no
id, or a different one (IDs are case-sensitive, soSalesdoesn't matchsales) - the
idsits on atdstyled to look like a heading, or on thetr, thetheador aspaninside theth, instead of on thethitself - the
thwith thatidbelongs to another table, like a nested table or a separate table used as a fixed header row
Screen readers rely on these references to tell users which headers a cell falls under. When an ID doesn't resolve to a th, the sub-header loses its link to the group. Put the id on the group header's th, changing a td into a th if needed. If that header no longer exists, remove its ID from headers.
The standard also forbids a header cell that targets itself, either directly or through a chain of other headers' headers attributes. The validator doesn't report that loop, so check for it yourself when header cells point at each other.
Invalid example
<table>
<tr>
<td id="sales" colspan="2">Sales</td>
</tr>
<tr>
<th headers="sales">Q1</th>
<th headers="sales">Q2</th>
</tr>
<tr>
<td>1200</td>
<td>1350</td>
</tr>
</table>
Valid example
<table>
<tr>
<th id="sales" colspan="2">Sales</th>
</tr>
<tr>
<th headers="sales">Q1</th>
<th headers="sales">Q2</th>
</tr>
<tr>
<td>1200</td>
<td>1350</td>
</tr>
</table>
The name attribute is not a valid attribute for the <th> element in HTML.
No HTML specification has defined name on header cells, so it has no effect on how the table renders or how assistive technology reads it. The validator reports it twice: this warning, and an error saying that the attribute is not allowed on th at this point. Both go away once the attribute is removed.
What to use instead depends on what the name was meant to do. To identify a header cell, give it an id. An id works as a link target (page.html#year), and data cells can point to it through their headers attribute. To mark a header as labeling its column or row, add scope="col" or scope="row". To keep a value that only scripts read, move it to a data-* attribute such as data-name.
Scripts that look these cells up with document.getElementsByName() still find them while the attribute is there, so update them to getElementById() or a [data-name] selector when you change the markup.
Invalid example
<table>
<tr>
<th name="year">Year</th>
<th name="revenue">Revenue</th>
</tr>
<tr>
<td>2024</td>
<td>1,200</td>
</tr>
</table>
Valid example
Using id and scope on the header cells, with headers on the data cells:
<table>
<tr>
<th id="year" scope="col">Year</th>
<th id="revenue" scope="col">Revenue</th>
</tr>
<tr>
<td headers="year">2024</td>
<td headers="revenue">1,200</td>
</tr>
</table>
If the value is only metadata for scripts, a data-* attribute works:
<table>
<tr>
<th data-name="year">Year</th>
<th data-name="revenue">Revenue</th>
</tr>
<tr>
<td>2024</td>
<td>1,200</td>
</tr>
</table>
A <th> gets its ARIA role from the table it belongs to, so it can't have a role attribute when that <table> has no role, or has role="table", role="grid" or role="treegrid".
In those tables the browser works out what each header cell is. A <th> inside <thead>, or in a row of headers above the data, is exposed as columnheader. A <th> at the start of a data row is exposed as rowheader. When the layout is ambiguous, scope="col" or scope="row" states the direction. Screen readers use these roles to announce the right header as the user moves from one data cell to the next.
The ARIA in HTML specification allows no role value at all on a <th> in these tables, so the validator reports this error whatever the value is. With role="columnheader" or role="rowheader" you also get a warning that the role is unnecessary for the element, and deleting the attribute clears both messages. Any other value replaces the header role. A sortable header marked role="button", or a header marked role="presentation", stops being a header, and screen readers no longer read its text before the cells it labels.
To make a column sortable, keep the <th> as it is, put a <button> inside it, and set aria-sort on the <th> for the column that is currently sorted. If the table exists only for layout, put role="presentation" on the <table> instead. A table with a role other than table, grid or treegrid loses its table semantics, and this rule no longer applies to its cells.
Table plugins and page builders often add a role to every part of a table: role="table" on the <table>, role="rowgroup" on <thead> and <tbody>, role="row" on each <tr> and role="cell" on each <td>. The validator reports all of them, with errors for the roles on <tr> and <td> and warnings for the rest, so remove them in the same change.
Invalid example
<table>
<thead>
<tr>
<th scope="col" role="columnheader">Product</th>
<th scope="col" role="button" tabindex="0">Price</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row" role="rowheader">Desk lamp</th>
<td>$45</td>
</tr>
<tr>
<th scope="row" role="rowheader">Notebook</th>
<td>$8</td>
</tr>
</tbody>
</table>
Valid example
<table>
<thead>
<tr>
<th scope="col">Product</th>
<th scope="col" aria-sort="descending">
<button type="button">Price</button>
</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Desk lamp</th>
<td>$45</td>
</tr>
<tr>
<th scope="row">Notebook</th>
<td>$8</td>
</tr>
</tbody>
</table>
A <th> at the start of a data row already has the implicit role rowheader, so adding role="rowheader" to it changes nothing for screen readers.
The browser gives each <th> its ARIA role based on where the cell sits in the table. Header cells in the <thead>, or in a top row of headers, become columnheader. A header cell followed by <td> cells in the same row becomes rowheader. Add scope="row" when you want to state the direction outright: it marks the cell as the header for the rest of its row. The role attribute repeats information the table structure already carries and only makes the markup longer.
You'll get this warning for role="rowheader" on every <th>, whatever its position. If the cell actually heads a column, for example a <th> inside <thead>, the role is also incorrect: it replaces columnheader, so screen readers announce the cell as the label of a row.
Expect an error alongside the warning in most tables. A <th> can't carry any role attribute when its <table> has no role of its own, or has role="table", role="grid" or role="treegrid". Deleting role="rowheader" clears the warning and the error together.
Table plugins and page builders tend to put a role on every row and cell. The rowheader role is for tables built from generic elements such as <div> or <span>, which have no native way to mark a cell as a row header. On a <th>, remove the attribute and keep scope="row". If the same table also sets roles on its <tr>, <td> or <tbody> elements, the validator flags those too, so remove them in the same change.
Invalid example
<table>
<thead>
<tr>
<th scope="col">Plan</th>
<th scope="col">Monthly price</th>
<th scope="col">Storage</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row" role="rowheader">Basic</th>
<td>$5</td>
<td>10 GB</td>
</tr>
<tr>
<th scope="row" role="rowheader">Team</th>
<td>$20</td>
<td>100 GB</td>
</tr>
</tbody>
</table>
Valid example
<table>
<thead>
<tr>
<th scope="col">Plan</th>
<th scope="col">Monthly price</th>
<th scope="col">Storage</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Basic</th>
<td>$5</td>
<td>10 GB</td>
</tr>
<tr>
<th scope="row">Team</th>
<td>$20</td>
<td>100 GB</td>
</tr>
</tbody>
</table>
The scope attribute tells browsers and assistive technologies how a header cell relates to the data cells around it. Its valid values are col, row, colgroup, and rowgroup. In older versions of HTML, scope was permitted on <td> elements, but the current HTML Living Standard restricts it to <th> elements only. When the W3C validator encounters scope on a <td>, it flags it as obsolete.
This matters for several reasons. First, if a cell acts as a header for other cells, it should be marked up as a <th>, not a <td>. Using <td scope="row"> sends conflicting signals — the element says "I'm a data cell" while the attribute says "I'm a header for this row." Second, screen readers rely on proper <th> elements with scope to announce table relationships. A <td> with scope may not be interpreted correctly, making the table harder to navigate for users of assistive technology. Third, using obsolete attributes means your markup doesn't conform to current standards, which could lead to unpredictable behavior in future browsers.
The fix is straightforward: if a cell has a scope attribute, it's acting as a header and should be a <th> element. Change the <td> to <th> and keep the scope attribute. If the cell is genuinely a data cell and not a header, remove the scope attribute entirely and leave it as a <td>.
Examples
Incorrect: scope on a <td> element
<table>
<tr>
<td scope="col">Name</td>
<td scope="col">Role</td>
</tr>
<tr>
<td scope="row">Alice</td>
<td>Engineer</td>
</tr>
</table>
This triggers the validation error because scope is used on <td> elements. The first row contains column headers and the first column contains row headers, yet they are all marked as data cells.
Correct: scope on <th> elements
<table>
<tr>
<th scope="col">Name</th>
<th scope="col">Role</th>
</tr>
<tr>
<th scope="row">Alice</th>
<td>Engineer</td>
</tr>
</table>
Now the header cells are correctly marked with <th>, and the scope attribute is valid on each one. Screen readers can properly associate "Alice" with "Engineer" and announce the column header "Role" when navigating to that cell.
A more complete table example
<table>
<thead>
<tr>
<th scope="col">Day</th>
<th scope="col">Morning</th>
<th scope="col">Afternoon</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Monday</th>
<td>Meeting</td>
<td>Code review</td>
</tr>
<tr>
<th scope="row">Tuesday</th>
<td>Workshop</td>
<td>Planning</td>
</tr>
</tbody>
</table>
Here, scope="col" on the column headers in <thead> tells assistive technology that "Day," "Morning," and "Afternoon" each apply to the cells below them. scope="row" on "Monday" and "Tuesday" indicates they apply to the cells in their respective rows. Every scope attribute sits on a <th>, so the markup is valid and accessible.
When to remove scope instead
If the cell truly contains data and isn't a header, simply remove the scope attribute:
<!-- Before (invalid) -->
<td scope="row">Some data</td>
<!-- After (valid) -->
<td>Some data</td>
Only add scope when a cell genuinely serves as a header. If it does, make it a <th>. If it doesn't, leave it as a plain <td> without scope.
Validate at scale.
Ship accessible websites, faster.
Automated HTML & accessibility validation for large sites. Check thousands of pages against WCAG guidelines and W3C standards in minutes, not days.
1K Weekly
1,000 monthly checks. Cancel anytime.
Get 1K Weekly →Join teams across 40+ countries