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
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#
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 & 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}");
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
lielements 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 exampleHtmlLimitExceededExceptionfor elements nested more than 4,096 deep. - A
Documentis an immutable snapshot that any number of threads can read at once.Nodeis a small struct that points into it. QuerySelectorreturns the first match in document order, andQuerySelectorAllreturns every match as aList<Node>. They take Selectors Level 4, including:has(),:is(),:where()and:not().- When nothing matches,
QuerySelectorreturns the null node. CheckIsNullbefore reading from it: callingGetAttributeon the null node throws. - Character references are already decoded, so
&reads as&.GetAttributereturnsnullwhen 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:
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);
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).
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 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:
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"));
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 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:
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"))); }
<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 < b & c</p>
- An open
pis closed by the nextpordiv. - Misnested
<b><i>…</b></i>becomes twoielements, so “italic” stays italic. - Text inside a
tablebut outside a cell is moved in front of the table, and rows get thetbodya browser adds. A selector written against the source, such astable > tr, matches nothing; usetable > tbody > trortable tr. - A stray
<or&is text.SerializeChildrenwrites 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). 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:
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));
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 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) | MIT (NuGet) | MIT (NuGet) |
| 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) | CSS selectors; XPath through AngleSharp.XPath |
| Loading from the web | No; you pass the bytes | HtmlWeb (docs) | BrowsingContext with a loader (README) |
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 comparison runs those inputs through all three and covers the rest of the differences.
On speed, the project’s published benchmark 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 asClosest,ParentElementandNextElementSibling. The HtmlAgilityPack migration guide 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: parse, query, edit, build the accessibility tree and check a page.
- CSS selectors in onehtml and the selector reference, with the Web Platform Tests results.
- Extract visible text from HTML in C#, without script and style.
- Edit HTML without reformatting it: changes that leave every other byte alone.