HTML Guides for table
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.
In HTML, the structure of a <table> is implicitly defined by its rows and columns. The first <tr> in a table (or table section like <thead>, <tbody>, or <tfoot>) establishes the expected column count for the entire table. When a subsequent row contains more cells than the first row, the validator raises this error because the table's column grid becomes inconsistent.
Browsers will still attempt to render mismatched tables, but the results can be unpredictable and problematic. Screen readers and other assistive technologies rely on a well-formed table grid to correctly associate data cells with their headers. An inconsistent column count can cause these tools to misread or skip content, making the table inaccessible to users who depend on them. Additionally, inconsistent tables are harder to style with CSS and can lead to unexpected layout shifts.
There are several common causes for this issue:
- Missing cells in the first row — The first row has fewer
<td>or<th>elements than subsequent rows. - Forgotten
colspan— A cell in the first row should span multiple columns but is missing acolspanattribute. - Extra cells in later rows — A row further down the table has more cells than intended.
- Mismatched
colspanarithmetic — The sum of cells and theircolspanvalues doesn't add up consistently across rows.
To fix this, review every row in the table and ensure the total column count (accounting for colspan and rowspan attributes) is the same for each row.
Examples
Incorrect: second row has more columns than the first
The first row defines 1 column, but the second row has 2 columns.
<table>
<tr>
<td>Liza</td>
</tr>
<tr>
<td>Jimmy</td>
<td>14</td>
</tr>
</table>
Fixed: add missing cells to the first row
Make both rows have 2 columns by adding a header or data cell to the first row.
<table>
<tr>
<th>Name</th>
<th>Age</th>
</tr>
<tr>
<td>Jimmy</td>
<td>14</td>
</tr>
</table>
Fixed: use colspan if the first row intentionally spans the full width
If the first row is meant to be a single spanning header, use colspan to match the total column count.
<table>
<tr>
<th colspan="2">Student Info</th>
</tr>
<tr>
<td>Jimmy</td>
<td>14</td>
</tr>
</table>
Incorrect: later row exceeds column count with extra cells
<table>
<tr>
<th>Name</th>
<th>Age</th>
</tr>
<tr>
<td>Liza</td>
<td>12</td>
<td>Extra cell</td>
</tr>
</table>
Fixed: remove the extra cell or expand the header row
<table>
<tr>
<th>Name</th>
<th>Age</th>
<th>Notes</th>
</tr>
<tr>
<td>Liza</td>
<td>12</td>
<td>Extra cell</td>
</tr>
</table>
Incorrect: colspan mismatch causes inconsistent totals
The first row spans 3 columns total (1 + colspan="2"), but the second row has 4 cells.
<table>
<tr>
<th>Name</th>
<th colspan="2">Contact</th>
</tr>
<tr>
<td>Liza</td>
<td>liza@example.com</td>
<td>555-0100</td>
<td>Room 4</td>
</tr>
</table>
Fixed: adjust colspan or cell count so all rows match
<table>
<tr>
<th>Name</th>
<th colspan="3">Contact & Location</th>
</tr>
<tr>
<td>Liza</td>
<td>liza@example.com</td>
<td>555-0100</td>
<td>Room 4</td>
</tr>
</table>
When debugging this issue, count the effective columns for each row by adding up the number of cells plus any additional columns contributed by colspan values (a colspan="3" cell counts as 3 columns). Every row in the table must produce the same total.
A row in this table has more cells than its <colgroup> and <col> elements declare, so some of its cells sit past the last column the markup defines.
When a table has column markup, that markup sets the column count before any row is read. Each <col> adds one column, or as many as its span attribute says. A <colgroup> with no <col> inside adds the number in its own span, which defaults to 1. A row is X columns wide when its cells add up to X, with each cell counting as many columns as its colspan. The validator reports this error once for every row wider than the Y columns the markup declares.
A common cause is an empty <colgroup></colgroup> that a table editor or template writes into every table. It still counts as one column, so every row of a table with two or more columns triggers the error. Another cause is a column added to the rows but not to the <colgroup>. Browsers still show the extra cells, but styles set on <col>, such as width or background-color, don't reach the columns the markup leaves out.
To fix it, declare every column the rows use: add the missing <col> elements, raise the span, or remove a <colgroup> that doesn't style anything.
Invalid example
The <colgroup> declares one column, but each row has two cells:
<table>
<colgroup>
<col class="item">
</colgroup>
<tr>
<th>Item</th>
<th>Price</th>
</tr>
<tr>
<td>Coffee</td>
<td>2.50</td>
</tr>
</table>
Valid example
One <col> for each column in the rows:
<table>
<colgroup>
<col class="item">
<col class="price">
</colgroup>
<tr>
<th>Item</th>
<th>Price</th>
</tr>
<tr>
<td>Coffee</td>
<td>2.50</td>
</tr>
</table>
HTML tables establish their column count based on the first row. When subsequent rows have fewer <td> or <th> cells than that initial row defines, the table structure becomes invalid. Browsers will typically render these tables by leaving blank space where the missing cells should be, but the underlying markup is malformed.
This matters for several important reasons. Screen readers and other assistive technologies rely on a consistent table structure to navigate cells and associate data with the correct headers. When cells are missing, users who depend on these tools may receive confusing or incorrect information. Additionally, inconsistent row widths can lead to unpredictable layout behavior across different browsers and make your table markup harder to maintain.
When counting columns, remember that the colspan attribute contributes to the total. A single <td colspan="3"> counts as three columns, not one. So if your first row has two <td> elements and one of them has colspan="2", the table is three columns wide, and every other row must also account for three columns.
How to Fix
There are several approaches depending on your intent:
- Add the missing cells — If data was accidentally omitted, add the appropriate
<td>or<th>elements to complete the row. - Use
colspan— If a cell should intentionally span multiple columns, use thecolspanattribute so the total column count matches. - Add empty cells — If a cell simply has no content, include an empty
<td></td>to maintain the structure.
Examples
❌ Row with fewer columns than the first row
The first row establishes a 3-column table, but the second row only has 2 cells:
<table>
<tr>
<td>Name</td>
<td>Role</td>
<td>Department</td>
</tr>
<tr>
<td>Alice</td>
<td>Engineer</td>
</tr>
</table>
✅ Fix by adding the missing cell
<table>
<tr>
<td>Name</td>
<td>Role</td>
<td>Department</td>
</tr>
<tr>
<td>Alice</td>
<td>Engineer</td>
<td>Product</td>
</tr>
</table>
✅ Fix by using colspan to span remaining columns
If the second row intentionally has fewer logical cells, use colspan so the total still matches:
<table>
<tr>
<td>Name</td>
<td>Role</td>
<td>Department</td>
</tr>
<tr>
<td>Alice</td>
<td colspan="2">Engineer — General</td>
</tr>
</table>
✅ Fix by adding an empty cell
If there's simply no data for that column, include an empty cell:
<table>
<tr>
<td>Name</td>
<td>Role</td>
<td>Department</td>
</tr>
<tr>
<td>Alice</td>
<td>Engineer</td>
<td></td>
</tr>
</table>
❌ Mismatch caused by colspan in the first row
Be careful when the first row uses colspan, as it increases the effective column count:
<table>
<tr>
<td colspan="2">Full Name</td>
<td>Role</td>
</tr>
<tr>
<td>Alice</td>
<td>Engineer</td>
</tr>
</table>
Here the first row spans 3 columns (2 + 1), but the second row only has 2 cells.
✅ Fix by matching the full column count
<table>
<tr>
<td colspan="2">Full Name</td>
<td>Role</td>
</tr>
<tr>
<td>Alice</td>
<td>Smith</td>
<td>Engineer</td>
</tr>
</table>
A row in this table has fewer cells than its <colgroup> and <col> elements declare, so the row ends before the last column the markup defines.
Column markup sets the column count before any row is read. Each <col> adds one column, or as many as its span attribute says, and a <colgroup> with no <col> inside adds the number in its own span. A row is X columns wide when its cells add up to X, counting each cell's colspan and any cell from a row above that reaches down with rowspan. The validator reports this error once for every row narrower than the Y columns the markup declares. When no row reaches column Y at all, it also reports that table column Y established by element col (or colgroup) has no cells beginning in it.
A common cause is a column deleted from the rows while its <col> stays in the <colgroup>, for example after removing a column in a visual table editor. Without column markup, a short row only gets a warning. With it, the validator reports an error.
To fix it, make the column markup and the rows agree. If the column is gone, remove its <col> or lower the span. If the column should stay, add the missing cells to each short row, with an empty <td></td> where there is no data. A colspan on the last cell of a short row also clears this error, but when no row has a cell that starts in the last column, the validator then reports that column as having no cells.
Invalid example
The <colgroup> declares four columns, but each row has three cells:
<table>
<colgroup>
<col class="item">
<col class="size">
<col class="price">
<col class="stock">
</colgroup>
<tr>
<th>Item</th>
<th>Size</th>
<th>Price</th>
</tr>
<tr>
<td>T-shirt</td>
<td>M</td>
<td>15.00</td>
</tr>
</table>
Valid example
The <col> for the removed column is gone, so the markup declares three columns:
<table>
<colgroup>
<col class="item">
<col class="size">
<col class="price">
</colgroup>
<tr>
<th>Item</th>
<th>Size</th>
<th>Price</th>
</tr>
<tr>
<td>T-shirt</td>
<td>M</td>
<td>15.00</td>
</tr>
</table>
An element with role="cell" has no ancestor with role="row", and no row claims it through aria-owns.
An ARIA table nests its roles in a fixed order: table, then an optional rowgroup, then row, then cell (or columnheader and rowheader). Screen readers use that structure to announce a cell's row and column and to move between cells. A cell outside any row has no position in the table, so it may be read as loose text or skipped.
The row doesn't have to be the cell's direct parent. Plain <div> or <span> wrappers between a role="row" element and its cells pass, so layout wrappers can stay. The error appears when no ancestor has the row role at all: cells placed straight inside role="table" or role="rowgroup", a row wrapper that only has a class, or a role="cell" used outside any table. When the cells can't sit inside the row in the DOM, give each one an id and list those ids in aria-owns on the row.
Newer versions of the validator word this message as an element with the “role” value “row”, with the same cause and fix.
For tabular data, a native <table> with <tr> and <td> gives you this structure without any role attributes.
Invalid example
The second row's wrapper has a class but no role="row":
<div role="table" aria-label="Team members">
<div role="row">
<div role="columnheader">Name</div>
<div role="columnheader">Role</div>
</div>
<div class="member">
<div role="cell">Alice</div>
<div role="cell">Designer</div>
</div>
</div>
Valid example
With role="row" on the wrapper, the cells belong to a row, even with an extra layout <div> in between:
<div role="table" aria-label="Team members">
<div role="row">
<div role="columnheader">Name</div>
<div role="columnheader">Role</div>
</div>
<div role="row">
<div class="row-inner">
<div role="cell">Alice</div>
<div role="cell">Designer</div>
</div>
</div>
</div>
Every element with role="cell" needs an ancestor with role="row", or a row that claims it through aria-owns.
An ARIA table nests its roles in a fixed order: table, then an optional rowgroup, then row, then cell (or columnheader and rowheader). Screen readers use that structure to announce a cell's row and column and to let people jump from cell to cell. A cell with no row above it has no position in the table, so a screen reader may read it as loose text or skip it during table navigation.
This usually happens when a table built from <div> elements has a wrapper for each row that carries a class for styling but never gets role="row". If that wrapper sits inside a role="rowgroup", the validator also reports that a child of the rowgroup must have role="row". Adding the missing role to the wrapper fixes both messages.
The row doesn't have to be the cell's direct parent. The validator accepts plain <div> or <span> wrappers between a role="row" element and its cells, so you can keep layout wrappers as long as some ancestor has the row role. When the cells can't sit inside the row in the DOM, give each cell an id and list those ids in aria-owns on the row.
Older validator versions word this message as an element with “role=row” instead of an element with the “role” value “row”, with the same cause and fix.
If the data is really tabular, a native <table> with <tr> and <td> gives you this structure without any role attributes.
Invalid example
Each plan wrapper groups two cells, but nothing marks it as a row.
<div role="table" aria-label="Subscription plans">
<div role="rowgroup">
<div class="plan">
<div role="cell">Basic</div>
<div role="cell">$10 per month</div>
</div>
<div class="plan">
<div role="cell">Pro</div>
<div role="cell">$25 per month</div>
</div>
</div>
</div>
Valid example
With role="row" on each wrapper, every cell has a row to belong to.
<div role="table" aria-label="Subscription plans">
<div role="rowgroup">
<div role="row" class="plan">
<div role="cell">Basic</div>
<div role="cell">$10 per month</div>
</div>
<div role="row" class="plan">
<div role="cell">Pro</div>
<div role="cell">$25 per month</div>
</div>
</div>
</div>
An element with role="row" must sit inside an element whose role is table, grid, treegrid, or rowgroup, or be claimed by one through aria-owns. A row on its own has no structure to belong to, so assistive technologies cannot tell which table or grid it is part of.
The row role describes one row of a composite structure. Those four allowed owners fall into two groups: table and rowgroup for static tabular data, and grid and treegrid for interactive widgets that support keyboard navigation. In every case the container supplies the context a screen reader needs to announce the row's position and its cells. Placed outside such a container, the row is orphaned and the whole table semantics break down.
The most reliable fix is to nest the role="row" element inside its container in the DOM. When your layout prevents that, keep the row where it is and point aria-owns from the container at the row's id.
Invalid example
The role="row" is a sibling of the table, so nothing contains or owns it:
<div role="table" aria-label="Prices"></div>
<div role="row">
<span role="cell">Basic</span>
<span role="cell">$9</span>
</div>
Valid example
Nest the row inside the element with role="table":
<div role="table" aria-label="Prices">
<div role="row">
<span role="cell">Basic</span>
<span role="cell">$9</span>
</div>
</div>
If the DOM nesting is not possible, leave the row in place and add aria-owns="price-row" to the container so assistive technologies still treat it as the owner.
An element with role="rowgroup" needs an ancestor with role="table", role="grid", or role="treegrid", or one of those elements has to claim it through aria-owns.
A rowgroup groups the rows of a table, the way <thead>, <tbody> and <tfoot> do in a native <table>. It only means something inside the structure it divides. With no table, grid or treegrid above it, a screen reader has no table to announce, so it can't report the number of rows and columns or let people move from cell to cell with its table commands.
This often shows up in cards or comparison layouts built from <div> elements. The rows and cells inside carry ARIA roles, but the outer container has only a class for styling. Add role="table" to that container and give it a name with aria-label or aria-labelledby, so screen readers can say which table it is. Pick grid or treegrid only when the widget lets people move between cells with the arrow keys.
The table doesn't have to be the rowgroup's direct parent. The validator accepts plain wrapper elements in between, as long as some ancestor has one of the three roles. When the rowgroup can't sit inside the table in the DOM, give it an id and list that id in aria-owns on the table.
If the content isn't really tabular, such as a short list of features in a card, remove role="rowgroup", role="row" and role="cell" together and use a list or plain text. If you remove only the rowgroup role, the validator reports each row instead. For real tabular data, a native <table> with <tbody>, <tr> and <td> gives you the same structure without any ARIA.
Invalid example
The rows sit in a rowgroup, but the wrapper around it has no table role.
<h2 id="specs-title">Technical details</h2>
<div class="specs">
<div role="rowgroup">
<div role="row">
<span role="cell">Weight</span>
<span role="cell">1.2 kg</span>
</div>
<div role="row">
<span role="cell">Battery life</span>
<span role="cell">18 hours</span>
</div>
</div>
</div>
Valid example
With role="table" and a name on the wrapper, the rowgroup has a table to belong to.
<h2 id="specs-title">Technical details</h2>
<div class="specs" role="table" aria-labelledby="specs-title">
<div role="rowgroup">
<div role="row">
<span role="cell">Weight</span>
<span role="cell">1.2 kg</span>
</div>
<div role="row">
<span role="cell">Battery life</span>
<span role="cell">18 hours</span>
</div>
</div>
</div>
The <table> element in HTML supports a limited set of attributes — primarily global attributes like class, id, and style. The height attribute was never part of the HTML standard for tables (unlike width, which was valid in HTML 4 but has since been deprecated). Despite this, many browsers historically accepted height on <table> as a non-standard extension, which led to its widespread but incorrect use.
Because height is not a recognized attribute on <table>, using it means your markup is invalid and may behave inconsistently across browsers. Some browsers might honor it, others might ignore it entirely, and future browser versions could change their behavior at any time. Relying on non-standard attributes makes your code fragile and harder to maintain.
The fix is straightforward: remove the height attribute from the <table> element and use CSS to set the desired height. You can apply the CSS inline via the style attribute, or better yet, use an external or internal stylesheet with a class or ID selector.
Examples
❌ Invalid: height attribute on <table>
<table height="300">
<tr>
<td>Name</td>
<td>Score</td>
</tr>
<tr>
<td>Alice</td>
<td>95</td>
</tr>
</table>
This triggers the validator error: Attribute "height" not allowed on element "table" at this point.
✅ Fixed: Using inline CSS
<table style="height: 300px;">
<tr>
<td>Name</td>
<td>Score</td>
</tr>
<tr>
<td>Alice</td>
<td>95</td>
</tr>
</table>
✅ Fixed: Using a CSS class (preferred)
<style>
.tall-table {
height: 300px;
}
</style>
<table class="tall-table">
<tr>
<td>Name</td>
<td>Score</td>
</tr>
<tr>
<td>Alice</td>
<td>95</td>
</tr>
</table>
Using a class keeps your HTML clean and makes it easy to adjust the styling later without touching the markup. Note that min-height is often a better choice than height for tables, since table content can naturally grow beyond a fixed height, and min-height ensures the table is at least a certain size without clipping content.
An empty colspan on a <td> is invalid, because the attribute takes the number of columns the cell spans, a whole number from 1 to 1000.
Both colspan="" and a bare colspan with no value get this error. A value made only of spaces, like colspan=" ", gets a different one that starts with "Expected a digit".
Empty attributes like this usually come from a table builder in a CMS, or from a template that prints colspan="{{ cell.colspan }}" on every cell, whether or not the cell spans anything. The same templates often print rowspan="" too, which gets its own error saying "non-negative integer" instead, because rowspan also accepts 0.
Browsers treat an empty colspan as 1, the same as leaving the attribute out, so the table looks the same once you remove it. Change the template to print colspan only when the cell spans two or more columns.
Invalid example
<table>
<tr>
<th>Plan</th>
<th>Price</th>
</tr>
<tr>
<td colspan="">Basic</td>
<td colspan="">$10</td>
</tr>
<tr>
<td colspan="2">Prices include tax.</td>
</tr>
</table>
Valid example
<table>
<tr>
<th>Plan</th>
<th>Price</th>
</tr>
<tr>
<td>Basic</td>
<td>$10</td>
</tr>
<tr>
<td colspan="2">Prices include tax.</td>
</tr>
</table>
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>
The rowspan attribute on a <td> must hold a whole number from 0 to 65534, so leaving it empty is an error.
A bare rowspan with no value counts as empty and gets the same message. A value made of spaces, or a negative number like -1, gets a different error that starts with "Expected a digit".
Empty rowspan attributes often come from table plugins and page builders that write both span attributes on every cell and leave them blank when no cells were merged. Check the same cells for colspan="", which the validator reports in its own message ending in "not a valid positive integer".
Browsers read an empty rowspan as 1, the same as no attribute, so deleting it leaves the table unchanged. Don't reach for rowspan="0" as a replacement: it is valid, unlike colspan="0", but it stretches the cell down through every remaining row of its <thead>, <tbody> or <tfoot>. Write the attribute only on cells that cover two or more rows.
Invalid example
<table>
<tr>
<th>Day</th>
<th>Time</th>
<th>Session</th>
</tr>
<tr>
<td rowspan="2">Monday</td>
<td rowspan="">9:00</td>
<td rowspan="">Welcome</td>
</tr>
<tr>
<td>11:00</td>
<td>Workshop</td>
</tr>
</table>
Valid example
<table>
<tr>
<th>Day</th>
<th>Time</th>
<th>Session</th>
</tr>
<tr>
<td rowspan="2">Monday</td>
<td>9:00</td>
<td>Welcome</td>
</tr>
<tr>
<td>11:00</td>
<td>Workshop</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 <td> takes digits only, so a single space after the number, as in colspan="2 ", makes the value invalid.
The space usually comes from a template. In <td colspan="{{ span }} "> or <td colspan="<?php echo $span; ?> ">, the space before the closing quote ends up in the attribute, right after the number. A helper that pads numbers to a fixed width has the same effect. A space before the number (" 2") or a line break inside the quotes gets the same error. The character after "saw" is the first one that isn't a digit: a blank here, p in 2px, % in 50%. A value with markup in it, like 2><span class=, means a closing quote is missing.
The HTML standard requires colspan to be a valid non-negative integer greater than zero and no larger than 1000, which is one or more digits with nothing around them. Browsers skip leading spaces, read the digits and ignore the rest, so "2 " still spans two columns. The validator's table check counts a cell with a trailing space as one column, though, so a "table row was N columns wide" warning often appears alongside this error.
Fix the template so the closing quote comes right after the number, or trim the value before printing it.
Invalid example
<table>
<tr>
<th>Item</th>
<th>Qty</th>
<th>Price</th>
</tr>
<tr>
<td>Notebook</td>
<td>2</td>
<td>$8</td>
</tr>
<tr>
<td colspan="2 ">Total</td>
<td>$8</td>
</tr>
</table>
Valid example
<table>
<tr>
<th>Item</th>
<th>Qty</th>
<th>Price</th>
</tr>
<tr>
<td>Notebook</td>
<td>2</td>
<td>$8</td>
</tr>
<tr>
<td colspan="2">Total</td>
<td>$8</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 defines a strict content model for tables. A <table> element can only contain <caption>, <colgroup>, <thead>, <tbody>, <tfoot>, and <tr> as direct children. A <tr> element can only contain <td> and <th> elements. When text or inline elements like <span>, <a>, or <strong> appear directly inside any of these table-structural elements, they violate the content model and trigger this validation error.
This matters for several reasons. Browsers handle misplaced table content inconsistently — some will silently move the text before the table, others may discard it, and some may attempt to create anonymous table cells. This leads to unpredictable layouts across browsers and devices. Screen readers rely on correct table structure to navigate cells and announce row/column relationships, so misplaced content can confuse assistive technology users. Additionally, any text placed directly inside a <tr> but outside a cell is technically not part of any row/column structure, making it semantically meaningless in the table context.
Common causes of this error include:
- Accidentally placing text between
<tr>and<td>tags - Placing a heading or paragraph directly inside a
<table>(use<caption>instead for table titles) - Using non-breaking spaces (
) or other characters directly inside<table>or<tr>for spacing - Incorrectly nesting non-table elements like
<div>,<p>, or<form>as direct children of<table>or<tr>
Examples
Text directly inside a table row
<!-- ❌ Wrong: text is a direct child of <tr> -->
<table>
<tr>
Hello World
<td>First Cell</td>
<td>Second Cell</td>
</tr>
</table>
<!-- ✅ Fixed: text is wrapped in a <td> -->
<table>
<tr>
<td>Hello World</td>
<td>First Cell</td>
<td>Second Cell</td>
</tr>
</table>
Text directly inside a table element
<!-- ❌ Wrong: heading and spacing text placed directly in <table> -->
<table>
Monthly Sales Report
<tr>
<th>Month</th>
<th>Revenue</th>
</tr>
</table>
<!-- ✅ Fixed: use <caption> for table titles -->
<table>
<caption>Monthly Sales Report</caption>
<tr>
<th>Month</th>
<th>Revenue</th>
</tr>
</table>
Non-breaking spaces used for spacing
<!-- ❌ Wrong: directly inside <tr> -->
<table>
<tr>
<td>Data</td>
</tr>
</table>
<!-- ✅ Fixed: use CSS for spacing, or place content in a cell -->
<table>
<tr>
<td>Data</td>
</tr>
</table>
Inline elements directly inside a table
<!-- ❌ Wrong: <a> tag directly inside <table> -->
<table>
<a href="/details">View details</a>
<tr>
<td>Item 1</td>
<td>$10.00</td>
</tr>
</table>
<!-- ✅ Fixed: move the link into a cell or outside the table -->
<table>
<tr>
<td>Item 1</td>
<td>$10.00</td>
<td><a href="/details">View details</a></td>
</tr>
</table>
Content between table sections
<!-- ❌ Wrong: text between <thead> and <tbody> -->
<table>
<thead>
<tr>
<th>Name</th>
</tr>
</thead>
No results found
<tbody>
<tr>
<td>—</td>
</tr>
</tbody>
</table>
<!-- ✅ Fixed: place the message inside a cell -->
<table>
<thead>
<tr>
<th>Name</th>
</tr>
</thead>
<tbody>
<tr>
<td>No results found</td>
</tr>
</tbody>
</table>
The HTML specification requires that every row in a table has at least one cell that starts on that row. A "cell beginning on a row" means a <td> or <th> element that is directly placed in that <tr>, as opposed to a cell that merely spans into it via rowspan from an earlier row. When the validator encounters a row with no cells beginning on it, it flags the error because the row is structurally meaningless — it contributes nothing to the table's data model.
This issue commonly arises in two scenarios:
- Empty
<tr>elements — A<tr>that contains no<td>or<th>children at all. This sometimes appears when developers use empty rows for visual spacing, or when content management systems generate leftover markup. - Rows fully covered by
rowspan— When cells in preceding rows userowspanvalues large enough to span over an entire subsequent row, that subsequent row ends up with no cells beginning on it, even if it technically "has" cells passing through it.
This matters for several reasons. Screen readers and other assistive technologies rely on a well-formed table structure to navigate cells and announce their content. An empty or fully-spanned row confuses this navigation. Browsers may also handle malformed tables inconsistently, leading to unexpected rendering. Ensuring every row has at least one cell that begins on it keeps your tables semantically correct and accessible.
How to fix it
- Remove empty rows. If a
<tr>has no cells and serves no purpose, delete it entirely. - Add cells to the row. If the row is intentional, populate it with
<td>or<th>elements (they can be empty if needed). - Adjust
rowspanvalues. If previous cells span too many rows, reduce theirrowspanso that every row still has at least one cell of its own. - Use CSS for spacing. If empty rows were used for visual spacing, use CSS
margin,padding, orborder-spacinginstead.
Note that self-closing <tr /> elements are treated the same as <tr></tr> — they produce an empty row and will trigger this error.
Examples
Empty row in <tbody> (incorrect)
<table>
<tbody>
<tr>
</tr>
<tr>
<td>Data</td>
</tr>
</tbody>
</table>
The first <tr> has no cells, so the validator reports that row 1 of the <tbody> row group has no cells beginning on it.
Empty row removed (correct)
<table>
<tbody>
<tr>
<td>Data</td>
</tr>
</tbody>
</table>
Row fully covered by rowspan (incorrect)
<table>
<tbody>
<tr>
<td rowspan="3">Spans three rows</td>
<td rowspan="3">Also spans three</td>
</tr>
<tr>
</tr>
<tr>
</tr>
</tbody>
</table>
Rows 2 and 3 have no cells beginning on them — all cells originate from row 1. Even though cells pass through those rows via rowspan, the validator still requires at least one cell to begin on each row.
Corrected rowspan with cells on every row (correct)
<table>
<tbody>
<tr>
<td rowspan="3">Spans three rows</td>
<td>Row 1 detail</td>
</tr>
<tr>
<td>Row 2 detail</td>
</tr>
<tr>
<td>Row 3 detail</td>
</tr>
</tbody>
</table>
Each row now has at least one <td> that begins on it, satisfying the requirement.
Using CSS instead of empty rows for spacing (correct)
<table>
<tbody>
<tr>
<td>First item</td>
</tr>
<tr style="height: 1.5em;">
<td>Second item with extra space above</td>
</tr>
</tbody>
</table>
Instead of inserting an empty row for spacing, apply CSS to the row or cells that need additional space.
The HTML <table> element follows a rigid structure. A <table> can only contain <caption>, <colgroup>, <thead>, <tbody>, <tfoot>, and <tr> as direct children. A <tr> can only contain <td> and <th> elements. Placing an <a> tag (or any other inline/flow content) directly inside <table>, <tr>, or other table-structural elements violates this content model, triggering the "Start tag a seen in table" validation error.
This matters for several reasons. Browsers handle invalid table markup inconsistently — some may silently move the misplaced <a> element outside the table entirely, while others may render it in unexpected positions. This leads to unpredictable layouts and broken links. Screen readers and other assistive technologies rely on proper table structure to navigate content, so misplaced elements can make the page confusing or inaccessible. Additionally, search engine crawlers may not correctly associate the link with its intended context.
The fix is straightforward: always wrap inline content like <a> inside a <td> or <th> element. Every piece of visible content in a table must live inside a table cell.
Examples
❌ Incorrect: <a> directly inside <tr>
<table>
<tr>
<a href="/details">View details</a>
</tr>
</table>
The <a> is a direct child of <tr>, which only allows <td> and <th> children.
❌ Incorrect: <a> directly inside <table>
<table>
<a href="/details">View details</a>
<tr>
<td>Data</td>
</tr>
</table>
The <a> is a direct child of <table>, which does not allow inline content.
❌ Incorrect: <a> directly inside <tbody>
<table>
<tbody>
<a href="/details">View details</a>
<tr>
<td>Data</td>
</tr>
</tbody>
</table>
The <a> is placed directly inside <tbody>, which only allows <tr> and <script>/<template> elements.
✅ Correct: <a> inside a <td>
<table>
<tr>
<td>
<a href="/details">View details</a>
</td>
</tr>
</table>
✅ Correct: <a> inside a <th>
<table>
<thead>
<tr>
<th>
<a href="/details">View details</a>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>Data</td>
</tr>
</tbody>
</table>
✅ Correct: wrapping an entire row's content in a link
If you want to make an entire row clickable, place the <a> inside each <td> rather than wrapping the <tr>:
<table>
<tr>
<td><a href="/item/1">Product name</a></td>
<td><a href="/item/1">$19.99</a></td>
<td><a href="/item/1">In stock</a></td>
</tr>
</table>
This keeps the table structure valid while still providing clickable content across the row. You can use CSS to remove the visual link styling and make each <a> fill its entire cell for a seamless clickable-row effect.
The <table> element in HTML has a rigid content model. Directly inside <table>, only specific elements are allowed: <caption>, <colgroup>, <thead>, <tbody>, <tfoot>, <tr>, and <script>-supporting elements. Similarly, <tr> elements may only contain <td> and <th> elements. A <br> tag placed between table rows, or directly inside a <tbody> or <tr> but outside a cell, violates this content model.
This error typically happens when developers try to add vertical spacing between table rows using <br> tags. Browsers handle this invalid markup inconsistently — some will push the <br> outside the table entirely, while others may silently ignore it. This leads to unpredictable layout behavior across browsers and can confuse assistive technologies that rely on proper table structure to convey data relationships to users.
The <br> element is only valid inside phrasing content contexts, such as within a <td>, <th>, <p>, <span>, or similar elements. If you need to add spacing between rows, use CSS (margin, padding, or border-spacing) instead of inserting <br> tags into the table structure.
How to fix it
- Remove the
<br>if it was added accidentally or as a formatting attempt between rows. - Move the
<br>inside a<td>or<th>if you intended it to create a line break within a cell's content. - Use CSS for spacing if you need visual separation between rows. Apply
paddingto cells or use theborder-spacingproperty on the table.
Examples
❌ Invalid: <br> between table rows
<table>
<tr>
<th>Item</th>
<th>Description</th>
</tr>
<br>
<tr>
<td>Book</td>
<td>A guide to HTML</td>
</tr>
</table>
❌ Invalid: <br> directly inside a <tr>
<table>
<tr>
<br>
<td>Book</td>
<td>A guide to HTML</td>
</tr>
</table>
✅ Fixed: <br> removed, CSS used for spacing
<table style="border-spacing: 0 1em;">
<tr>
<th>Item</th>
<th>Description</th>
</tr>
<tr>
<td>Book</td>
<td>A guide to HTML</td>
</tr>
</table>
✅ Valid: <br> used inside a table cell
A <br> element is perfectly valid inside a <td> or <th>, where it functions as a line break within the cell's content.
<table>
<tr>
<th>Item</th>
<th>Description</th>
</tr>
<tr>
<td>Book</td>
<td>
Title: HTML & CSS<br>
Author: Jon Duckett
</td>
</tr>
</table>
✅ Fixed: Using padding for row spacing
If your goal is to create visual separation between rows, CSS padding on cells is the cleanest approach:
<style>
.spaced-table td,
.spaced-table th {
padding: 1em 0.5em;
}
</style>
<table class="spaced-table">
<tr>
<th>Item</th>
<th>Description</th>
</tr>
<tr>
<td>Book</td>
<td>A guide to HTML</td>
</tr>
</table>
<cation> is a misspelling of <caption>: rename the opening and closing tags to <caption> so the table gets its title.
HTML has no <cation> element, so the parser handles it like any unknown tag. A <table> only accepts <caption>, <colgroup>, <thead>, <tbody>, <tfoot> and <tr> as children (plus <script> and <template>), and an unknown element isn't one of them.
When the browser finds <cation> inside a table, it moves the element and its text out and puts them just before the table. The HTML specification calls this foster parenting. The text still shows up above the table, which makes the typo easy to miss on screen, but the table itself has no caption.
Screen readers announce a table's <caption> as its name. Without one, someone jumping from table to table hears the table with no title, and the stray text before it reads as an ordinary line. A caption hidden with a "visually hidden" class, written only for screen reader users, loses its whole purpose.
The validator stops checking the page at this error and adds Cannot recover after last error. Any further errors will be ignored. right after it. Nothing below that point gets checked, so new issues further down the page can show up once you fix this one.
To fix it, correct the spelling in both tags. Keep the <caption> as the first child of the <table>, since the HTML specification doesn't allow it anywhere else.
Invalid example
<table>
<cation>Monthly sales</cation>
<tr>
<th>Month</th>
<th>Total</th>
</tr>
<tr>
<td>January</td>
<td>1,200</td>
</tr>
</table>
Valid example
<table>
<caption>Monthly sales</caption>
<tr>
<th>Month</th>
<th>Total</th>
</tr>
<tr>
<td>January</td>
<td>1,200</td>
</tr>
</table>
A table can only hold another table inside one of its cells, so a <table> start tag placed directly inside an open table, outside any <td> or <th>, is an error.
Between <table> and </table>, HTML allows only table parts: <caption>, <colgroup>, <thead>, <tbody>, <tfoot> and <tr>. Content, a nested table included, goes in a <td> or <th> cell. When the parser meets a new <table> tag anywhere else in an open table, it closes the open table at that point and starts the new one after it. The two tables end up as siblings in the DOM, and the </table> written for the outer table has nothing left to close, so the validator also reports a stray end tag.
Two mistakes usually cause this. One is a missing </table>: a table never gets closed, and the next table on the page starts inside it. The other is a wrapper table with another table directly inside it and no <tr> or <td> in between, such as one left behind by a WYSIWYG editor.
Close the first table before the next one starts, or remove the extra wrapper. If you do want one table inside another, put the inner table in a <td> of the outer one. To center a table or add space around it, use CSS on the table itself instead of wrapping it in another table.
Invalid example
<table>
<table>
<tr>
<th>Day</th>
<th>Opening hours</th>
</tr>
<tr>
<td>Monday</td>
<td>9:00 to 17:00</td>
</tr>
</table>
</table>
Valid example
<table>
<tr>
<th>Day</th>
<th>Opening hours</th>
</tr>
<tr>
<td>Monday</td>
<td>9:00 to 17:00</td>
</tr>
</table>
A <form> can't sit directly inside a <table>, <thead>, <tbody>, <tfoot> or <tr>: wrap the whole table in the form, or put the form inside a single <td> or <th> cell.
Table elements only accept specific children. A <table> holds <caption>, <colgroup>, <thead>, <tbody>, <tfoot> and <tr>, and a <tr> holds <td> and <th>. A <form> wrapped around one or more rows breaks that structure.
The browser's parser handles this case in a special way. It creates the form as an empty element at the spot where the tag appears, closes it right away, and drops the matching </form> end tag, which the validator reports as a separate Stray end tag “form” error. Browsers still link the fields in the following rows to that empty form, so submitting often appears to work. But the fields are not inside the form in the DOM: a selector like form input won't find them, and if a script moves or re-renders those rows, the fields lose their form.
When each row needs its own form, such as an "Update" button per cart item, keep the <form> elements outside the table and link each field and button to its form with the form attribute, which takes the form's id.
Invalid example
<table>
<form action="/cart" method="post">
<tr>
<td><label for="qty">Quantity</label></td>
<td><input type="number" id="qty" name="qty"></td>
<td><button type="submit">Update</button></td>
</tr>
</form>
</table>
Valid example
<form action="/cart" method="post">
<table>
<tr>
<td><label for="qty">Quantity</label></td>
<td><input type="number" id="qty" name="qty"></td>
<td><button type="submit">Update</button></td>
</tr>
</table>
</form>
Valid example with one form per row
<form id="item-1" action="/cart/1" method="post"></form>
<form id="item-2" action="/cart/2" method="post"></form>
<table>
<tr>
<td><input type="number" name="qty" form="item-1" aria-label="Quantity for item 1"></td>
<td><button type="submit" form="item-1">Update</button></td>
</tr>
<tr>
<td><input type="number" name="qty" form="item-2" aria-label="Quantity for item 2"></td>
<td><button type="submit" form="item-2">Update</button></td>
</tr>
</table>
An <input> can't sit directly inside a <table>, <thead>, <tbody>, <tfoot> or <tr>: put it inside a <td> or <th> cell, or move it outside the table.
Table elements only accept specific children. A <table> holds <caption>, <colgroup>, <thead>, <tbody>, <tfoot> and <tr>, and a <tr> holds <td> and <th>.
When the browser's parser finds a misplaced <input>, it moves the element out and inserts it just before the table. The HTML specification calls this foster parenting. The field then shows up above the table instead of in the row where you wrote it. Hidden inputs (type="hidden") are the exception: the parser leaves them in place, but the validator flags them too.
This usually comes from forms laid out with tables, where hidden fields such as CSRF tokens or page numbers end up between <tr> elements. Move hidden inputs before or after the <table>, keeping them inside the <form>, and put visible inputs in a cell. If the <form> tag itself sits inside the table, move it out so it wraps the whole <table>. A <form> between rows triggers its own validation error.
Invalid example
<form action="/search">
<table>
<tr>
<td><label for="q">Search</label></td>
<td><input type="text" id="q" name="q"></td>
</tr>
<input type="hidden" name="page" value="1">
</table>
</form>
Valid example
<form action="/search">
<input type="hidden" name="page" value="1">
<table>
<tr>
<td><label for="q">Search</label></td>
<td><input type="text" id="q" name="q"></td>
</tr>
</table>
</form>
A <p> can't sit directly inside a <table>, <thead>, <tbody>, <tfoot> or <tr>: put the paragraph inside a <td> or <th> cell, or move it outside the table.
Table elements only accept specific children. A <table> holds <caption>, <colgroup>, <thead>, <tbody>, <tfoot> and <tr>, and a <tr> holds <td> and <th>. A paragraph is allowed inside a cell or a <caption>, but not between rows.
When the browser's parser finds a misplaced <p>, it moves the paragraph out and inserts it just before the table. The HTML specification calls this foster parenting. A paragraph you wrote as a table title or a note between rows shows up above the table instead, and an empty <p></p> left between rows becomes an empty paragraph above it.
The validator stops checking the page at this error and adds Cannot recover after last error. Any further errors will be ignored. right after it. Nothing below that point gets checked, so new issues further down the page can show up once you fix this one.
To fix it, put a table title in a <caption>, move text that belongs to a row into a <td> or <th>, and delete empty <p></p> pairs between rows. If those empty paragraphs were there to add space between rows, use CSS padding on the cells or border-spacing on the table instead.
Invalid example
<table>
<p>Opening hours</p>
<tr>
<td>Monday</td>
<td>9:00 to 17:00</td>
</tr>
<p></p>
<tr>
<td>Saturday</td>
<td>Closed</td>
</tr>
</table>
Valid example
<table>
<caption>Opening hours</caption>
<tr>
<td>Monday</td>
<td>9:00 to 17:00</td>
</tr>
<tr>
<td>Saturday</td>
<td>Closed</td>
</tr>
</table>
A <caption> element only belongs inside a <table>, as its first child, so the validator reports any <caption> start tag it finds anywhere else.
The <caption> element is the title of a table. When the HTML parser meets a <caption> tag outside a table, it drops the tag: the DOM gets no caption element, and whatever was inside it becomes plain content of the surrounding element. The parser drops the closing tag as well, so the same spot usually also gets a "Stray end tag caption" error.
A common source is an image caption written as <caption> around an <img>. WordPress's [caption] shortcode turns into this markup when its square brackets get swapped for angle brackets, for example after pasting content from one editor into another. To caption an image, use a <figure> with a <figcaption> inside it.
If the caption belongs to a table, move it inside the <table> as its first child. A <caption> inside the table but after the rows gets a different error, saying caption isn't allowed as a child of table in that context.
Invalid example
<div class="photo">
<caption>
<img src="harbor.jpg" alt="Fishing boats moored in a harbor">
Fishing boats in the harbor at sunrise.
</caption>
</div>
<caption>Opening hours</caption>
<table>
<tr>
<th>Monday</th>
<td>9:00 to 17:00</td>
</tr>
</table>
Valid example
<figure class="photo">
<img src="harbor.jpg" alt="Fishing boats moored in a harbor">
<figcaption>Fishing boats in the harbor at sunrise.</figcaption>
</figure>
<table>
<caption>Opening hours</caption>
<tr>
<th>Monday</th>
<td>9:00 to 17:00</td>
</tr>
</table>
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