---
title: "Parse HTML in C#"
url: https://recursiveai.org/onehtml/articles/parse-html-csharp/
description: "Parse HTML in C# the way a browser does: load a string, file or URL, query it with CSS selectors, and handle broken markup and legacy encodings."
date: 2026-10-26
author: onehtml team
---

# Parse HTML in C#

Parse HTML in C# the way a browser does: load a string, file or URL, query it with CSS selectors, and handle broken markup and legacy encodings.

October 26, 2026 · onehtml team

To parse HTML in C#, add the `RecursiveAI.OneHtml` package, pass a string or the page’s bytes to `Document.Parse`, and query the result with CSS selectors through `QuerySelector` and `QuerySelectorAll`. onehtml is an HTML parser for .NET that follows the WHATWG HTML standard, so broken markup turns into the same tree a browser builds, and when you give it bytes it picks the encoding the way a browser does. It is pure C# with no dependency outside .NET, licensed MIT OR Apache-2.0. This page covers strings, files, URLs, malformed markup and fragments, then compares it with HtmlAgilityPack and AngleSharp. Every sample was compiled and run against onehtml 1.0.0 on .NET 10.

The .NET base class library has no HTML parser of its own. `System.Xml` needs well-formed XML, which most real HTML isn’t, so a parser library is the practical route.

## Install

*Terminal · NuGet*

```
dotnet new console -o HtmlParsing
cd HtmlParsing
dotnet add package RecursiveAI.OneHtml --version 1.0.0
```

onehtml targets .NET 10 (`net10.0`), works with NativeAOT and trimming, and never reads files or the network itself: you hand it a string or bytes.

## Parse an HTML string in C#

*Program.cs · onehtml 1.0.0 · .NET 10*

```
using RecursiveAI.OneHtml;
 
string html = """
    <ul id="products">
      <li class="product" data-sku="K-100"><a href="/p/kettle">Kettle</a> <span class="price">$39.00</span>
      <li class="product sale" data-sku="T-200"><a href="/p/toaster">Toaster</a> <span class="price">$24.50</span>
      <li class="product" data-sku="M-300"><a href="/p/mixer">Stand mixer &amp; bowl</a> <span class="price">$189.00</span>
    </ul>
    """;
 
Document doc = Document.Parse(html);
 
foreach (Node item in doc.Root.QuerySelectorAll("li.product"))
{
    Node link = item.QuerySelector("a");
    string price = item.QuerySelector(".price").TextContent!;
    Console.WriteLine($"{item.GetAttribute("data-sku")}  {link.TextContent,-20} {price,8}  {link.GetAttribute("href")}");
}
 
Node sale = doc.Root.QuerySelector("li.sale a");
Console.WriteLine($"On sale: {sale.TextContent}");
 
Node missing = doc.Root.QuerySelector("li.discontinued");
Console.WriteLine($"Discontinued found: {!missing.IsNull}");
```

*Output · Console*

```
K-100  Kettle                 $39.00  /p/kettle
T-200  Toaster                $24.50  /p/toaster
M-300  Stand mixer & bowl    $189.00  /p/mixer
On sale: Toaster
Discontinued found: False
```

- Malformed markup doesn’t throw. The `li` elements above have no end tags, and the parser closes each one where the standard says to, as a browser would. Only the safety limits throw, for example `HtmlLimitExceededException` for elements nested more than 4,096 deep.
- A `Document` is an immutable snapshot that any number of threads can read at once. `Node` is a small struct that points into it.
- `QuerySelector` returns the first match in document order, and `QuerySelectorAll` returns every match as a `List<Node>`. They take Selectors Level 4, including `:has()`, `:is()`, `:where()` and `:not()`.
- When nothing matches, `QuerySelector` returns the null node. Check `IsNull` before reading from it: calling `GetAttribute` on the null node throws.
- Character references are already decoded, so `&amp;` reads as `&`. `GetAttribute` returns `null` when the attribute is absent and `""` when it is present but empty.

## Load an HTML document from a file

Read the file as bytes and pass the bytes. The parser then finds the encoding the way a browser does: a byte order mark first, then the `Content-Type` you pass in `ParseOptions`, then a `<meta charset>` near the top of the file. This sample writes a small windows-1252 page, the kind older sites and CMS exports still produce, then loads it both ways:

*Program.cs · onehtml 1.0.0 · .NET 10*

```
using System.Text;
using RecursiveAI.OneHtml;
 
// Make a legacy page: saved as windows-1252 and declared with a <meta> tag
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
File.WriteAllBytes("menu.html", Encoding.GetEncoding(1252).GetBytes(
    "<!DOCTYPE html><meta charset=\"windows-1252\"><title>Menu</title><h1>Café crème – €3.20</h1>"));
 
// Pass the bytes: the parser picks the encoding the way a browser does
byte[] bytes = File.ReadAllBytes("menu.html");
Document doc = Document.Parse(bytes);
Console.WriteLine($"{doc.Source.Encoding.Name} ({doc.Source.Confidence}), {doc.Mode}");
Console.WriteLine(doc.Root.QuerySelector("h1").TextContent);
 
// Reading the file as a string first decodes it as UTF-8 before the parser sees the <meta>
Document wrong = Document.Parse(File.ReadAllText("menu.html"));
Console.WriteLine(wrong.Root.QuerySelector("h1").TextContent);
```

*Output · Console*

```
windows-1252 (Certain), NoQuirks
Café crème – €3.20
Caf� cr�me � �3.20
```

From bytes, the parser reads the `<meta>` tag and decodes the text correctly. `File.ReadAllText` decodes the file as UTF-8 before the parser ever sees that tag, so every non-ASCII character is lost. `Encoding.RegisterProvider` is there only to write the sample file: onehtml decodes the WHATWG encodings itself. When a page declares nothing at all, the parser falls back to windows-1252, as the standard recommends. If you know your files are UTF-8, set `ParseOptions.DefaultEncoding` (see [detect the encoding](https://recursiveai.org/onehtml/cookbook/detect-encoding/)).

`Document.Parse` takes a string, bytes (`ReadOnlyMemory<byte>`, so a `byte[]` works) or a `SourceText`, not a `Stream`. To parse a stream, copy it into a `MemoryStream` first, as the [parse HTML recipe](https://recursiveai.org/onehtml/cookbook/parse-html/) shows.

## Parse HTML from a URL

onehtml doesn’t download anything. Fetch the page with `HttpClient`, then pass the bytes and the response’s `Content-Type` header, so a charset in the header wins over the page’s `<meta>`, as it does in a browser:

*Program.cs · onehtml 1.0.0 · .NET 10*

```
using RecursiveAI.OneHtml;
 
using var http = new HttpClient();
using HttpResponseMessage response = await http.GetAsync("https://example.com/");
response.EnsureSuccessStatusCode();
byte[] body = await response.Content.ReadAsByteArrayAsync();
 
// The charset in the Content-Type header takes priority over a <meta> tag, as in a browser
var options = new ParseOptions { ContentType = response.Content.Headers.ContentType?.ToString() };
Document doc = Document.Parse(body, options);
 
Console.WriteLine($"Content-Type: {options.ContentType}, decoded as {doc.Source.Encoding.Name}");
Console.WriteLine($"Title: {doc.Root.QuerySelector("title").TextContent}");
Console.WriteLine($"Language: {doc.DocumentElement.GetAttribute("lang")}");
 
// QuerySelector returns a null node when nothing matches: check IsNull before using it
Node link = doc.Root.QuerySelector("a[href]");
Console.WriteLine(link.IsNull ? "No links on this page" : link.GetAttribute("href"));
```

*Output · Console*

```
Content-Type: text/html; charset=utf-8, decoded as UTF-8
Title: Example Domain
Language: en
No links on this page
```

Scripts are kept as inert text and never run. If the content you need is built by JavaScript, parse the DOM a real browser produced instead, for example the string from Playwright’s `Page.ContentAsync()`, which the [Playwright accessibility testing](https://recursiveai.org/onehtml/articles/playwright-accessibility-testing-csharp/) article uses.

## Malformed HTML: the tree a browser builds

Real pages leave out end tags, misnest formatting and put text in the wrong part of a table. The HTML standard defines exactly what tree each of these becomes, and onehtml implements that algorithm, including the tokenizer, every insertion mode, the adoption agency algorithm and foster parenting:

*Program.cs · onehtml 1.0.0 · .NET 10*

```
using RecursiveAI.OneHtml;
 
string[] inputs =
{
    "<p>One<p>Two<div>Three</div>",
    "<b>bold <i>both</b> italic</i>",
    "<table><tr><td>1</td></tr>Total<tr><td>2</td></tr></table>",
    "<ul><li>One<li>Two</ul><p>a < b & c",
};
 
foreach (string html in inputs)
{
    Document doc = Document.Parse(html);
    Console.WriteLine(doc.SerializeChildren(doc.Root.QuerySelector("body")));
}
```

*Output · Console*

```
<p>One</p><p>Two</p><div>Three</div>
<b>bold <i>both</i></b><i> italic</i>
Total<table><tbody><tr><td>1</td></tr><tr><td>2</td></tr></tbody></table>
<ul><li>One</li><li>Two</li></ul><p>a &lt; b &amp; c</p>
```

- An open `p` is closed by the next `p` or `div`.
- Misnested `<b><i>…</b></i>` becomes two `i` elements, so “italic” stays italic.
- Text inside a `table` but outside a cell is moved in front of the table, and rows get the `tbody` a browser adds. A selector written against the source, such as `table > tr`, matches nothing; use `table > tbody > tr` or `table tr`.
- A stray `<` or `&` is text. `SerializeChildren` writes it back escaped.

The parser is tested against the html5lib tokenizer and encoding tests and the Web Platform Tests for tree construction, with every deliberate exception listed with its reason. On 18,330 crawled pages and stylesheets, parsing and writing back with no edits gives byte-identical output ([conformance table](https://github.com/recursive-ai-oss/onehtml-csharp/blob/v1.0.0/README.md#conformance)). To record the parse errors a page has, set `ParseOptions.RecordErrors`.

## Parse an HTML fragment

A fragment such as a table row or a list item means something different depending on where it goes. `Document.ParseFragment` parses it as the inner HTML of a context element, as `element.innerHTML` does in a browser:

*Program.cs · onehtml 1.0.0 · .NET 10*

```
using RecursiveAI.OneHtml;
 
// Parse markup as the inner HTML of a <tbody>, the way element.innerHTML does
var tbody = new FragmentContext(ElementNamespace.Html, "tbody");
Document rows = Document.ParseFragment("<tr><td>Kettle<td>$39.00<tr><td>Toaster<td>$24.50", tbody);
 
Console.WriteLine($"Rows: {rows.Root.QuerySelectorAll("tr").Count}");
Console.WriteLine(rows.SerializeChildren(rows.Root));
 
// The same markup inside a <div>: table parts outside a table are dropped
Document loose = Document.ParseFragment("<tr><td>Kettle<td>$39.00", new FragmentContext(ElementNamespace.Html, "div"));
Console.WriteLine(loose.SerializeChildren(loose.Root));
```

*Output · Console*

```
Rows: 2
<tr><td>Kettle</td><td>$39.00</td></tr><tr><td>Toaster</td><td>$24.50</td></tr>
Kettle$39.00
```

`FragmentContext.FromElement` takes the context from an element of a parsed page. The [HTML fragments recipe](https://recursiveai.org/onehtml/cookbook/html-fragments/) has more.

## Choosing a .NET HTML parser

The other two common choices are HtmlAgilityPack and AngleSharp. Here is how the three compare on what each publisher states, for the versions on NuGet on 26 October 2026. The builds row lists the `lib` folders of each published package.

|  | onehtml 1.0.0 | HtmlAgilityPack 1.13.1 | AngleSharp 1.8.6 |
|---|---|---|---|
| License | MIT OR Apache-2.0 ([NuGet](https://www.nuget.org/packages/RecursiveAI.OneHtml/1.0.0)) | MIT ([NuGet](https://www.nuget.org/packages/HtmlAgilityPack/1.13.1)) | MIT ([NuGet](https://www.nuget.org/packages/AngleSharp/1.8.6)) |
| Builds in the package | .NET 10 | .NET Framework 3.5, 4.0 and 4.5, .NET 8, .NET Standard 2.0, UAP 10.0, portable profiles | .NET 10 and 8, .NET Standard 2.0, .NET Framework 4.6.2 and 4.7.2 |
| Parsing, as described by the publisher | “a WHATWG-conformant parser” | “very tolerant with ‘real world’ malformed HTML” | “construct a DOM based on the official W3C specification” |
| Queries | CSS Selectors Level 4; no XPath | XPath; CSS through the third-party Fizzler package ([HAP site](https://html-agility-pack.net/)) | CSS selectors; XPath through [AngleSharp.XPath](https://www.nuget.org/packages/AngleSharp.XPath/2.0.10) |
| Loading from the web | No; you pass the bytes | `HtmlWeb` ([docs](https://html-agility-pack.net/from-web)) | `BrowsingContext` with a loader ([README](https://github.com/AngleSharp/AngleSharp)) |

The trees differ too. On the misnested and table inputs above, HtmlAgilityPack 1.13.1 keeps “italic” outside any `i` element and leaves “Total” inside the table, while AngleSharp 1.8.6 and onehtml build the browser’s tree. The [HtmlAgilityPack vs AngleSharp](https://recursiveai.org/onehtml/articles/htmlagilitypack-vs-anglesharp/) comparison runs those inputs through all three and covers the rest of the differences.

On speed, the project’s [published benchmark](https://github.com/recursive-ai-oss/onehtml-csharp/blob/v1.0.0/README.md#performance) parses a 100-page sample of crawled pages from bytes in 125.0 ms per sweep with onehtml, 341.7 ms with AngleSharp 1.8.6 and 292.8 ms with HtmlAgilityPack 1.13.1. It ran on an AMD Ryzen 9 7950X3D with .NET 10, using BenchmarkDotNet in process.

## What onehtml doesn’t do

- **No XPath.** Queries are CSS selectors plus ordinary C#: LINQ over `QuerySelectorAll`, and DOM navigation such as `Closest`, `ParentElement` and `NextElementSibling`. The [HtmlAgilityPack migration guide](https://recursiveai.org/onehtml/articles/migrate-from-htmlagilitypack/) translates common XPath expressions.
- **No fetching and no script.** You supply bytes, and scripts stay as text.
- **No layout or painting,** so no screenshots and no HTML to PDF.
- **.NET 10 only.** There is no .NET Framework or .NET Standard build.

## Where next

- [Getting started with onehtml](https://recursiveai.org/onehtml/docs/getting-started/): parse, query, edit, build the accessibility tree and check a page.
- [CSS selectors in onehtml](https://recursiveai.org/onehtml/cookbook/css-selectors/) and the [selector reference](https://recursiveai.org/onehtml/docs/selectors/), with the Web Platform Tests results.
- [Extract visible text from HTML in C#](https://recursiveai.org/onehtml/articles/extract-visible-text-from-html-csharp/), without script and style.
- [Edit HTML without reformatting it](https://recursiveai.org/onehtml/cookbook/edit-html-csharp-lossless/): changes that leave every other byte alone.
