Turn an HTML table into data

Get the rows and the columns of a table published in a web page, merged cells included.

No AI needed

FreeInstantYour data never leaves

The verdict

N0, because the HTML specification names the elements and describes the algorithm that turns them into a grid. This entry’s work is to apply that algorithm and to say what it costs. A merged cell is spread over the places it covers, and every repeat is flagged: it is the only way to keep the columns aligned without suggesting the page wrote three times what it wrote once. A table inside a cell is returned separately, its text does not flow into the cell holding it, and that cell is flagged — the classic table-scraping trap. A span is capped at the two maximums the specification allows — a thousand for `colspan`, 65,534 for `rowspan` — and `capped` says how many times the cap was used; a rowspan of zero covers the rest of the row group, as the specification asks. And the caption comes back with the grid, because it is often the caption that says what the columns mean.

The grid the reader sees, with the spans spread out and flagged

No AI needed

Rule and classic algorithmN0FreeInstant

Python

snippets/extract-a-table-from-a-web-page/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
A table published in a web page, turned into a rectangular grid.

Rung N0. The rows and the cells are elements the HTML specification names, so
reading them is parsing. What is not parsing is the one thing that makes tables
hard: a cell can span several columns or several rows, and the markup grid is
then not the data grid. « Total » written once over three columns is one cell
in the source and three cells in the table a reader sees.

This rung builds the grid a reader sees. A spanned cell is repeated over the
places it covers, and every repeat is flagged — a caller that adds up a column
would otherwise count the same amount twice without knowing it. The other
choice, leaving holes, misaligns every column after the span, which is worse
because nothing tells you.

Two more things are reported rather than smoothed over. A table inside a cell
of another table is a table of its own: its text does not leak into the cell
that holds it, and the holder is flagged. And the caption, when the page wrote
one, comes back with the grid, because it is usually what the columns mean.
"""

from __future__ import annotations

from html.parser import HTMLParser

CELLS = {"td", "th"}

# The largest spans the HTML specification allows, and they are not the same
# number: colspan « must be greater than zero and less than or equal to 1000 »,
# rowspan « must be greater than zero and less than or equal to 65534 ». The
# grid-forming algorithm repeats both caps. A page can claim more than it
# draws, and past these the claim is capped rather than believed — `capped` in
# the report counts how often.
MAX_COLSPAN = 1000
MAX_ROWSPAN = 65534

# « For this attribute, the value zero means that the cell is to span all the
# remaining rows in the row group. » Kept as zero here and resolved in `_grid`,
# which is the only place that knows how many rows follow. Row groups are not
# tracked, so « the row group » is read as « the table », which is the same
# thing on a table with one `tbody` — the ordinary case.
TO_END_OF_GROUP = 0


class _Tables(HTMLParser):
    """Tables, rows and cells, with the nesting kept straight."""

    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.tables, self._stack = [], []

    def handle_starttag(self, tag, attrs):
        values = dict(attrs)
        if tag == "table":
            if self._stack:
                self._stack[-1]["nested"] = True
            table = {"rows": [], "caption": None, "nested": False,
                     "cell": None, "in_caption": False, "row_open": False,
                     "capped": 0}
            # Listed in the order the page opens them, so a table that holds
            # another comes before it — the order a reader would give.
            self.tables.append(table)
            self._stack.append(table)
        elif not self._stack:
            return
        elif tag == "tr":
            self._stack[-1]["rows"].append([])
            self._stack[-1]["row_open"] = True
        elif tag in CELLS:
            # « in table body »: a `td` met outside a `tr` opens one. Adding it
            # to the row that just closed would widen every row of the table.
            if not self._stack[-1]["rows"] or not self._stack[-1].get("row_open"):
                self._stack[-1]["rows"].append([])
                self._stack[-1]["row_open"] = True
            cell = {"text": [], "header": tag == "th",
                    "colspan": self._span(values.get("colspan"), MAX_COLSPAN),
                    "rowspan": self._span(values.get("rowspan"), MAX_ROWSPAN,
                                          zero_allowed=True)}
            self._stack[-1]["rows"][-1].append(cell)
            self._stack[-1]["cell"] = cell
        elif tag == "caption":
            self._stack[-1]["in_caption"] = True

    def _span(self, value, largest: int, *, zero_allowed: bool = False) -> int:
        """One span attribute, read as the standard reads it."""
        try:
            number = int(str(value))
        except (TypeError, ValueError):
            return 1
        if zero_allowed and number == 0:
            return TO_END_OF_GROUP
        if number > largest:
            self._stack[-1]["capped"] += 1
            return largest
        return max(1, number)

    def handle_endtag(self, tag):
        if not self._stack:
            return
        if tag == "table":
            self._stack.pop()
        elif tag == "tr":
            self._stack[-1]["row_open"] = False
        elif tag in CELLS:
            self._stack[-1]["cell"] = None
        elif tag == "caption":
            self._stack[-1]["in_caption"] = False

    def handle_data(self, data):
        if not self._stack:
            return
        top = self._stack[-1]
        if top["in_caption"]:
            top["caption"] = (top["caption"] or "") + data
        elif top["cell"] is not None:
            top["cell"]["text"].append(data)


def extract_tables(html) -> dict:
    """
    Every table of `html`, as the rectangular grid a reader sees.

    A cell that spans is repeated over what it covers, and each repeat says so.
    """
    if not isinstance(html, str):
        return {"tables": [], "reason": f"expected text, not {type(html).__name__}"}
    parser = _Tables()
    parser.feed(html)
    parser.close()

    tables = []
    for table in parser.tables:
        grid, padded = _grid(table["rows"])
        tables.append({"rows": grid, "columns": len(grid[0]) if grid else 0,
                       # How many cells the rectangular padding added, and how
                       # many spans the caps cut back. Without them, `columns`
                       # says a width and never says where it came from.
                       "padded": padded, "capped": table["capped"],
                       "caption": " ".join((table["caption"] or "").split()) or None,
                       "nested": table["nested"]})
    return {"tables": tables, "reason": None}


def _grid(rows) -> tuple:
    """The markup rows, spread over the places their spans cover."""
    grid: list = []
    for index, row in enumerate(rows):
        while len(grid) <= index:
            grid.append([])
        column = 0
        for cell in row:
            while column < len(grid[index]) and grid[index][column] is not None:
                column += 1
            value = {"text": " ".join("".join(cell["text"]).split()),
                     "header": cell["header"], "repeated": False}
            # A rowspan of zero covers every row left in the group.
            down_to = (len(rows) - index if cell["rowspan"] == TO_END_OF_GROUP
                       else cell["rowspan"])
            for down in range(down_to):
                while len(grid) <= index + down:
                    grid.append([])
                line = grid[index + down]
                for across in range(cell["colspan"]):
                    while len(line) <= column + across:
                        line.append(None)
                    if line[column + across] is None:
                        line[column + across] = value if (down or across) == 0 else \
                            {**value, "repeated": True}
            column += cell["colspan"]
    width = max((len(line) for line in grid), default=0)
    padded = sum(width - len(line) for line in grid) + sum(
        1 for line in grid for cell in line if cell is None)
    return ([[cell or {"text": "", "header": False, "repeated": False}
              for cell in line + [None] * (width - len(line))] for line in grid], padded)

JavaScript

snippets/extract-a-table-from-a-web-page/n0.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * A table published in a web page, turned into a rectangular grid.
 *
 * Rung N0. The rows and the cells are elements the HTML specification names,
 * so reading them is parsing. What is not parsing is the one thing that makes
 * tables hard: a cell can span several columns or several rows, and the markup
 * grid is then not the data grid. « Total » written once over three columns is
 * one cell in the source and three cells in the table a reader sees.
 *
 * This rung builds the grid a reader sees. A spanned cell is repeated over the
 * places it covers, and every repeat is flagged — a caller that adds up a
 * column would otherwise count the same amount twice without knowing it. The
 * other choice, leaving holes, misaligns every column after the span, which is
 * worse because nothing tells you.
 *
 * Two more things are reported rather than smoothed over. A table inside a
 * cell of another table is a table of its own: its text does not leak into the
 * cell that holds it, and the holder is flagged. And the caption, when the
 * page wrote one, comes back with the grid, because it is usually what the
 * columns mean.
 */

import { parseHTML } from 'linkedom';

// The largest spans the HTML specification allows, and they are not the same
// number: colspan « must be greater than zero and less than or equal to 1000 »,
// rowspan « must be greater than zero and less than or equal to 65534 ». The
// grid-forming algorithm repeats both caps. A page can claim more than it
// draws, and past these the claim is capped rather than believed — `capped` in
// the report counts how often.
export const MAX_COLSPAN = 1000;
export const MAX_ROWSPAN = 65534;

// « For this attribute, the value zero means that the cell is to span all the
// remaining rows in the row group. » Kept as zero here and resolved in
// `gridOf`, which is the only place that knows how many rows follow. Row
// groups are not tracked, so « the row group » is read as « the table », which
// is the same thing on a table with one `tbody` — the ordinary case.
export const TO_END_OF_GROUP = 0;

/**
 * Every table of `html`, as the rectangular grid a reader sees.
 *
 * A cell that spans is repeated over what it covers, and each repeat says so.
 */
export function extractTables(html) {
  if (typeof html !== 'string') {
    return { tables: [], reason: `expected text, not ${typeof html}` };
  }
  const { document } = parseHTML(`<html><body>${html}</body></html>`);

  // Listed in the order the page opens them, so a table that holds another
  // comes before it — the order a reader would give.
  const found = [];
  const collect = (node) => {
    if (node.tagName === 'TABLE') found.push(node);
    for (const child of node.children ?? []) collect(child);
  };
  collect(document.body);

  const tables = found.map((table) => {
    const cells = rowsOf(table).map((row) => row.map(cellOf));
    const { grid, padded } = gridOf(cells);
    const capped = cells.flat().filter((cell) => cell.capped).length;
    const caption = ownCaption(table);
    return {
      rows: grid,
      columns: grid.length ? grid[0].length : 0,
      // How many cells the rectangular padding added, and how many spans the
      // caps cut back. Without them, `columns` says a width and never says
      // where it came from.
      padded,
      capped,
      caption: caption ? caption.split(/\s+/).filter(Boolean).join(' ') : null,
      nested: table.querySelector('table') !== null,
    };
  });
  return { tables, reason: null };
}

/** The rows of this table, and not those of a table inside it. */
function rowsOf(table) {
  const rows = [];
  // True while the last row opened is the implicit one a stray cell created.
  let orphan = false;
  const walk = (node) => {
    for (const child of node.children) {
      if (child.tagName === 'TABLE') continue;
      if (child.tagName === 'TR') {
        rows.push([]);
        orphan = false;
      } else if (child.tagName === 'TD' || child.tagName === 'TH') {
        // « in table body »: a cell met outside a `tr` opens one. Adding it to
        // the row that just closed would widen every row of the table.
        if (rows.length === 0 || !orphan) { rows.push([]); orphan = true; }
        rows[rows.length - 1].push(child);
        continue;
      }
      if (child.tagName === 'TR') {
        for (const cell of child.children) {
          if (cell.tagName === 'TD' || cell.tagName === 'TH') rows[rows.length - 1].push(cell);
        }
        orphan = false;
      } else {
        walk(child);
      }
    }
  };
  walk(table);
  return rows;
}

function cellOf(cell) {
  const colspan = span(cell.getAttribute('colspan'), MAX_COLSPAN);
  const rowspan = span(cell.getAttribute('rowspan'), MAX_ROWSPAN, true);
  return {
    text: ownText(cell),
    header: cell.tagName === 'TH',
    colspan: colspan.value,
    rowspan: rowspan.value,
    capped: colspan.capped || rowspan.capped,
  };
}

/** The text this element carries itself, a table inside it excepted. */
function ownText(element) {
  let out = '';
  const walk = (node) => {
    for (const child of node.childNodes) {
      if (child.nodeType === 3) out += child.nodeValue;
      else if (child.nodeType === 1 && child.tagName !== 'TABLE') walk(child);
    }
  };
  walk(element);
  return out;
}

function ownCaption(table) {
  for (const child of table.children) {
    if (child.tagName === 'CAPTION') return ownText(child);
  }
  return null;
}

/** The markup rows, spread over the places their spans cover. */
function gridOf(rows) {
  const grid = [];
  rows.forEach((row, index) => {
    while (grid.length <= index) grid.push([]);
    let column = 0;
    for (const cell of row) {
      while (column < grid[index].length && grid[index][column] !== null) column += 1;
      const value = {
        text: cell.text.split(/\s+/).filter(Boolean).join(' '),
        header: cell.header,
        repeated: false,
      };
      // A rowspan of zero covers every row left in the group.
      const downTo = cell.rowspan === TO_END_OF_GROUP ? rows.length - index : cell.rowspan;
      for (let down = 0; down < downTo; down += 1) {
        while (grid.length <= index + down) grid.push([]);
        const line = grid[index + down];
        for (let across = 0; across < cell.colspan; across += 1) {
          while (line.length <= column + across) line.push(null);
          if (line[column + across] === null) {
            line[column + across] = down === 0 && across === 0
              ? value : { ...value, repeated: true };
          }
        }
      }
      column += cell.colspan;
    }
  });
  const width = grid.reduce((most, line) => Math.max(most, line.length), 0);
  let padded = 0;
  const full = grid.map((line) => {
    const complete = [...line];
    while (complete.length < width) complete.push(null);
    padded += complete.filter((cell) => cell === null).length;
    return complete.map((cell) => cell ?? { text: '', header: false, repeated: false });
  });
  return { grid: full, padded };
}

/** One span attribute, read as the standard reads it. */
function span(value, largest, zeroAllowed = false) {
  const number = Number.parseInt(String(value), 10);
  if (Number.isNaN(number)) return { value: 1, capped: false };
  if (zeroAllowed && number === 0) return { value: TO_END_OF_GROUP, capped: false };
  if (number > largest) return { value: largest, capped: true };
  return { value: Math.max(1, number), capped: false };
}

Try it

This code cannot run in a browser. The outputs below are computed when the site is built, by running the real code.

A cell in brackets is the repeat of a merged cell, not a value written in the page. The six tables below are read by the real snippet, when the site is built.

  1. A table with no row at all

    Given

    <table><caption>Commande à venir</caption></table>

    What it returns

    0 row(s) over 0 column(s)

    Caption: Commande à venir.

    In detail

  2. A row shorter than the others

    Given

    <table><tr><td>Moulin</td><td>2</td></tr><tr><td>Vélo</td></tr></table>

    What it returns

    2 row(s) over 2 column(s)

    In detail

    12
    Moulin2
    Vélo
  3. A plain table, no merged cells

    Given

    <table><tr><th>Article</th><th>Quantité</th></tr><tr><td>Moulin</td><td>2</td></tr><tr><td>Vélo</td><td>1</td></tr></table>

    What it returns

    3 row(s) over 2 column(s)

    In detail

    ArticleQuantité
    Moulin2
    Vélo1
  4. The same, with a caption and sections

    Given

    <table><caption>Commande 2026-118</caption><thead><tr><th>Article</th><th>Quantité</th></tr></thead><tbody><tr><td>Moulin</td><td>2</td></tr><tr><td>Vélo</td><td>1</td></tr></tbody></table>

    What it returns

    3 row(s) over 2 column(s)

    Caption: Commande 2026-118.

    In detail

    ArticleQuantité
    Moulin2
    Vélo1
  5. A table inside a cell of another

    Given

    <table><tr><td>Détail<table><tr><td>interne</td></tr></table></td><td>Voisine</td></tr></table>

    What it returns

    1 row(s) over 2 column(s) · 2 tables found, the first one is shown

    This table holds another one, returned separately.

    In detail

    12
    DétailVoisine
  6. Merged cells, across and down

    Given

    <table><tr><th>Article</th><th colspan="2">Prix</th></tr><tr><td>Moulin</td><td>HT</td><td>TTC</td></tr><tr><td rowspan="2">Lot</td><td>1</td><td>2</td></tr><tr><td>3</td><td>4</td></tr><tr><td colspan="3">Total</td></tr></table>

    What it returns

    5 row(s) over 3 column(s)

    In detail

    ArticlePrixPrix
    MoulinHTTTC
    Lot12
    (Lot)34
    Total(Total)(Total)

    Here it breaksThe grid returned is indeed the one the reader sees — three columns on every row — but it holds values the page wrote only once: “Prix” appears twice, “Lot” twice, “Total” three times. It is the only way to keep the columns aligned, and it is a trap for anyone adding up a column without looking. The repeats are flagged, and shown here in brackets; the snippet does not tell them apart in any other way, because nothing in the page does.

What this means for you

  • Your data stays put
  • Always the same result
  • Unit-testable, case by case
  • Depends on a library
  • Negligible footprint

What this adds to your regulatory scope

  • No specific scope added: the page is read inside your own process and is sent to nobody
  • Scraping a third-party site is a matter of its terms of use and of applicable law, which the technique used does not change

Breaking point

Spreading a span out means repeating a value the page wrote only once: “Total” written over three columns comes back three times, and a caller adding up that column would count the same amount three times. Witness in the same test: every repeat carries a flag, and a row with no span raises none.

When to move up a level

Your page’s table is not a table: it is a run of `div` elements laid out in columns by the stylesheet, or an image. In the first case there is no specification to read and the page’s own selectors are the authority; in the second, the question becomes character recognition, and that is another entry.

The four options

  1. RecommendedN0The grid the reader sees, with the spans spread out and flaggedFree · Instant
  2. N1Not worth it here
  3. N2Not worth it here
  4. N3Not worth it here

The other options

N1Not worth it here

Not worth it here

Lightweight classic modelN1

There is nothing to learn: tr, td, th, colspan and rowspan are named by the HTML specification, and the algorithm that turns them into a grid is written there. A model trained on annotated pages would recover, at best, what that algorithm gives exactly, and it would be wrong on the layouts its sample did not contain.

N2Not worth it here

Not worth it here

Small self-hosted specialised modelN2

Table structure recognition models exist, and they are useful — on images. That is the subject of the entry on tables in a PDF, where there is sometimes no structure to read at all. Here the structure is written down: rendering the page to an image to have a model read it back would mean throwing away what the page declares in order to guess it again.

N3Not worth it here

Not worth it here

General-purpose LLM APIN3

A general-purpose model returns a table that resembles the page’s, and the resemblance is paid for exactly where this entry is careful: on merged cells it chooses — repeat, or leave blank — without saying so. Add to that one bill per page and one data egress, for work your language’s parser does in a few milliseconds.

Further reading

Where the figures come from

Metadata