---
title: "Parse an HTML table in C#"
url: https://recursiveai.org/onehtml/cookbook/parse-html-table/
description: "Parse an HTML table in C#: read the header row and every data row into records or a CSV, with correct handling of missing tbody and end tags. Runnable code."
version: "1.5.1"
---

# Parse an HTML table in C#

Select the table’s rows with `table tr`, read the `th` cells as column names and the `td` cells as values. The program below turns a table into records and a CSV file.

```shell
dotnet new console -o HtmlTable && cd HtmlTable
dotnet add package RecursiveAI.OneHtml --version 1.5.1
```

Put this in `Program.cs` and run `dotnet run`:

```csharp
using RecursiveAI.OneHtml;

Document doc = Document.Parse("""
    <table id="rates">
      <caption>Exchange rates</caption>
      <tr><th>Currency<th>Code<th>Rate
      <tr><td>Euro<td>EUR<td>0.92
      <tr><td>Pound sterling<td>GBP<td>0.79
      <tr><td>Japanese yen<td>JPY<td>&nbsp;149.30
    </table>
    """);

Node table = doc.Root.QuerySelector("#rates");

// The parser adds the tbody a browser adds, so "table tr" is safer than "table > tr"
List<Node> rows = table.QuerySelectorAll("tr");
string[] columns = Cells(rows[0], "th");
Console.WriteLine($"Columns: {string.Join(", ", columns)}");

var records = new List<Dictionary<string, string>>();
foreach (Node row in rows.Skip(1))
{
    string[] values = Cells(row, "td");
    records.Add(columns.Zip(values).ToDictionary(p => p.First, p => p.Second));
}

foreach (var r in records)
{
    Console.WriteLine($"{r["Code"]}: {r["Rate"]} ({r["Currency"]})");
}

// CSV: quote every field, double any quotes inside
var csv = rows.Select(row => string.Join(',', Cells(row, "th, td")
    .Select(v => $"\"{v.Replace("\"", "\"\"")}\"")));
File.WriteAllLines("rates.csv", csv);
Console.Write(File.ReadAllText("rates.csv"));

// The trimmed text of each cell; &nbsp; becomes U+00A0, which Trim removes
static string[] Cells(Node row, string selector) =>
    row.QuerySelectorAll(selector).Select(c => c.TextContent!.Trim()).ToArray();
```

```text
Columns: Currency, Code, Rate
EUR: 0.92 (Euro)
GBP: 0.79 (Pound sterling)
JPY: 149.30 (Japanese yen)
"Currency","Code","Rate"
"Euro","EUR","0.92"
"Pound sterling","GBP","0.79"
"Japanese yen","JPY","149.30"
```

- **End tags can be missing.** None of the `tr`, `th` or `td` elements above are closed, and the parser closes each where a browser would.
- **A `tbody` is always there.** The parser inserts one, as browsers do, so `table > tr` matches nothing. Use `table tr`, or `table > tbody > tr` to skip a `thead`.
- **Values are text.** Character references are decoded; parse numbers yourself with `CultureInfo.InvariantCulture`.

To load the page from a file or URL first, see [Parse HTML in C#](https://recursiveai.org/onehtml/cookbook/parse-html/).

## Tables with a thead, and nested tables

When the header row is in a `thead`, read it from there and take the rows from `tbody`. The `:scope` child combinator keeps a nested table’s rows out:

```csharp
using RecursiveAI.OneHtml;

Document doc = Document.Parse("""
    <table>
      <thead><tr><th>Plan</th><th>Includes</th></tr></thead>
      <tbody>
        <tr><td>Basic</td><td>Email support</td></tr>
        <tr><td>Team</td>
            <td><table><tr><td>Chat</td><td>Phone</td></tr></table></td></tr>
      </tbody>
    </table>
    """);

Node table = doc.Root.QuerySelector("table");
var head = table.QuerySelectorAll(":scope > thead > tr > th")
    .Select(c => c.TextContent);
Console.WriteLine(string.Join(" | ", head));
foreach (Node row in table.QuerySelectorAll(":scope > tbody > tr"))
{
    var cells = row.QuerySelectorAll(":scope > td").Select(c => c.TextContent);
    Console.WriteLine(string.Join(" | ", cells));
}
Console.WriteLine($"All rows, nested included: {table.QuerySelectorAll("tr").Count}");
```

```text
Plan | Includes
Basic | Email support
Team | ChatPhone
All rows, nested included: 4
```

## Limits

- **`colspan` and `rowspan` aren’t expanded.** A spanning cell is one `td`; read `GetAttribute("colspan")` and repeat the value yourself if your data needs a full grid.
- **Only the markup.** A table filled in by JavaScript is empty in the server’s HTML; see [Web scraping in C#](https://recursiveai.org/onehtml/cookbook/web-scraping/) for rendering it first.
- **Cell text is all of a cell’s text,** including any nested table’s. Select narrower elements when cells hold more than a value.
