From the standard library

Python’s standard library ships HTML primitives in the html package. html.escape() and html.unescape() handle entity encoding and decoding, html.entities exposes the reference tables, and html.parser.HTMLParser is a SAX-style tokenizer you subclass and drive with handle_* callbacks. These are the zero-dependency, always-available building blocks that ship with CPython; many scripts, templating helpers, and scrapers reach for them because they are already installed. The scope stops at tokenizing and entity work: html.parser does not build a document tree, does not implement WHATWG error recovery, and is explicitly documented as not fully HTML5-conformant.

turbohtml covers that same ground and extends past it. turbohtml.escape() and turbohtml.unescape() match the stdlib functions byte for byte, turbohtml.tokenize() and turbohtml.Tokenizer replace the callback tokenizer, and turbohtml.migration.stdlib.HTMLParser keeps your existing handle_* subclass working unchanged. Everything runs over a WHATWG-conformant C core that also builds a full parse tree, which html.parser has no equivalent for. The same C core also covers unicodedata’s Unicode normalization, so turbohtml.detect.normalize() is a drop-in for unicodedata.normalize().

turbohtml vs stdlib

Dimension

turbohtml

stdlib (html)

Scope

Escape/unescape, tokenizer, and full WHATWG tree construction

Escape/unescape, entity tables, and a tokenizer only (no tree)

Feature breadth

Tokens, tree, selectors, serialization, plus the callback shim

escape/unescape, html.entities tables, HTMLParser callbacks

Performance

SIMD scanning, several times faster on escape/unescape

Pure-Python entity scan and tokenizer

Typing

Fully type annotated across the public surface

Annotated in typeshed stubs, not conformant behavior

Dependencies

Compiled C extension (wheels), installed from PyPI

Built into CPython, zero install

Maintenance

Actively developed, tracks the WHATWG spec

Stable CPython module, html.parser frozen as non-conformant

Feature overlap

The shared surface ports one-to-one:

What turbohtml adds

  • WHATWG-conformant tokenizing and tree construction via turbohtml.parse() and turbohtml.parse_fragment(); html.parser tokenizes but never builds a tree and is documented as not HTML5-conformant.

  • A token stream you drive yourself through turbohtml.tokenize() and turbohtml.Tokenizer, instead of inverting control into callbacks.

  • An event-driven parse, turbohtml.saxparse.sax_parse(), that keeps html.parser’s callback shape but fires on the constructed tree – implied tags, foster parenting, the adoption agency – which the standard library never builds.

  • Verbatim source capture per token (capture_source=Truetoken.source) and unresolved reference tokens (resolve_references=FalseTokenType.CHARACTER_REFERENCE).

  • SIMD-accelerated escape/unescape scanning.

What stdlib has that turbohtml does not

  • html.parser and the html functions are built into CPython with no install step. turbohtml ships a compiled extension from PyPI; in environments that cannot install wheels or build C, the stdlib remains the only option.

  • html.entities exposes the raw reference tables (name2codepoint, codepoint2name, html5) as public data. turbohtml resolves references through escape/unescape and the tokenizer rather than exposing the dicts; if you consume those tables directly, keep importing html.entities.

Performance

operation

turbohtml

stdlib

escape — tiny plain (64 B)

58.6 ns

126 ns (2.2x ±3%)

escape — medium markup (4 KiB)

2.43 µs

7.94 µs (3.3x ±2%)

escape — no-op prose (4 MiB)

123 µs

2.74 ms (22.3x ±2%)

escape — book text (3 MiB)

654 µs

2.89 ms (4.5x ±1%)

escape — book HTML (4 MiB)

1.31 ms

5.08 ms (3.9x ±1%)

escape — spec HTML, dense (4 MiB)

5.34 ms

13.9 ms (2.7x ±1%)

escape — UCS-2 plain (4 MiB)

844 µs

2.65 ms (3.2x ±2%)

escape — UCS-2 markup (4 MiB)

5.79 ms

11.8 ms (2.1x ±1%)

escape — UCS-4 plain (4 MiB)

976 µs

5.69 ms (5.9x ±1%)

escape — UCS-4 markup (4 MiB)

7.06 ms

20.8 ms (3.0x ±3%)

unescape — tiny plain (64 B)

35.9 ns

39.8 ns (1.2x ±2%)

unescape — medium dense refs (4 KiB)

4.92 µs

74.1 µs (15.1x ±1%)

unescape — numeric refs (4 KiB)

5.17 µs

82.8 µs (16.1x ±1%)

unescape — book HTML, real refs (4 MiB)

1.95 ms

9.16 ms (4.7x ±9%)

unescape — escaped book HTML (5 MiB)

1.7 ms

19.7 ms (11.6x ±6%)

unescape — dense refs (4 MiB)

5.6 ms

72.6 ms (13.0x ±4%)

unescape — UCS-2 refs (4 MiB)

1.89 ms

18.3 ms (9.7x ±2%)

tokenize — typical markup

28.6 µs

426 µs (15.0x ±2%)

tokenize — text-heavy prose

595 ns

2.79 µs (4.7x ±3%)

tokenize — attribute-heavy

18.3 µs

298 µs (16.3x ±3%)

tokenize — script-heavy

11.6 µs

147 µs (12.8x ±2%)

tokenize — entity-heavy

17.3 µs

195 µs (11.3x ±3%)

tokenize — wpt tiny (0.6 kB)

1.45 µs

17.5 µs (12.2x ±2%)

tokenize — wpt small (4 kB)

11.9 µs

168 µs (14.2x ±2%)

tokenize — wpt medium (9.6 kB)

27.7 µs

367 µs (13.3x ±2%)

tokenize — wpt large (92 kB)

329 µs

3.93 ms (12.0x ±3%)

tokenize — wpt CJK (124 kB)

563 µs

8.34 ms (14.9x ±3%)

tokenize — whatwg spec (235 kB)

651 µs

7.47 ms (11.5x ±3%)

tokenize — ecmascript spec (3 MB)

6.27 ms

53.2 ms (8.5x ±2%)

tokenize — whatwg spec source (7.9 MB)

37 ms

369 ms (10.0x ±1%)

feed and dispatch a page — daring fireball (10 kB)

98.9 µs

1.1 ms (11.2x ±19%)

feed and dispatch a page — ars technica (56 kB)

494 µs

3.89 ms (7.9x ±18%)

feed and dispatch a page — mozilla blog (95 kB)

1.24 ms

9.34 ms (7.6x ±47%)

feed and dispatch a page — whatwg spec (235 kB)

2.61 ms

24.7 ms (9.5x ±21%)

SAX parse a page (no tree) — daring fireball (10 kB)

45.3 µs

322 µs (7.2x ±3%)

SAX parse a page (no tree) — ars technica (56 kB)

214 µs

1.46 ms (6.9x ±4%)

SAX parse a page (no tree) — mozilla blog (95 kB)

471 µs

3.77 ms (8.1x ±12%)

SAX parse a page (no tree) — whatwg spec (235 kB)

1.14 ms

8.21 ms (7.3x ±8%)

decode a legacy byte stream — shift_jis japanese (8 kB)1

5.4 µs

10.4 µs (2.0x ±3%)

decode a legacy byte stream — gb18030 astral (16 kB)1

22.8 µs

17.1 µs (0.8x ±3%)

decode a legacy byte stream — windows-1252 french (9 kB)1

2.58 µs

2.8 µs (1.1x ±3%)

decode a legacy byte stream — gb18030 japanese (8 kB)1

6.14 µs

15.5 µs (2.6x ±5%)

decode a legacy byte stream — iso-2022-jp japanese (8 kB)1

8.97 µs

15.2 µs (1.7x ±6%)

1 decodes with the nearest CPython codec under errors=replace, which is not the WHATWG decoder of that label: the two disagree on both the mapping tables and where decoding resumes after an error

turbohtml.escape() and turbohtml.unescape() reproduce the standard-library functions byte for byte, so they are drop-ins, but scan with SIMD and run several times faster.

How to migrate

Swap the imports and, if you subclass the parser, swap the base class:

stdlib call

turbohtml call

html.escape(s)

turbohtml.escape(s)

html.unescape(s)

turbohtml.unescape(s)

class P(html.parser.HTMLParser)

class P(turbohtml.migration.stdlib.HTMLParser)

handle_starttag(tag, attrs)

token.type is TokenType.START_TAGtoken.tag, token.attrs

handle_startendtag(tag, attrs)

TokenType.START_TAG with token.self_closing

handle_endtag(tag)

TokenType.END_TAGtoken.tag

handle_data(data)

TokenType.TEXTtoken.data

handle_comment(data)

TokenType.COMMENTtoken.data

handle_decl(decl)

TokenType.DOCTYPEtoken.name

handle_entityref/handle_charref

tokenize(..., resolve_references=False)TokenType.CHARACTER_REFERENCE, else resolved in token.data

get_starttag_text()

tokenize(..., capture_source=True)token.source

Escape and unescape are literal drop-ins:

import html
from turbohtml import escape, unescape

print(escape('<a href="x">') == html.escape('<a href="x">'))
print(unescape("caf&eacute; &#127881;") == html.unescape("caf&eacute; &#127881;"))
True
True

To keep an existing html.parser.HTMLParser subclass, swap its base class for turbohtml.migration.stdlib.HTMLParser: the same handle_* callbacks and feed/close methods run over the WHATWG-conformant tokenizer. Or drop the subclass and take the token stream from turbohtml.tokenize() (or turbohtml.Tokenizer.feed() for incremental input), or skip tokens entirely and turbohtml.parse() straight to a tree. All three are WHATWG-conformant, unlike html.parser. The How-to guides guide has a worked port.

HTMLParser is a SAX-style callback API; turbohtml gives you the events as a token stream you drive yourself, which inverts the control flow. Each handle_* override becomes a branch on Token.type:

import turbohtml
from turbohtml import TokenType

events = []
for token in turbohtml.tokenize('<p class="x">Hi &amp; bye</p>'):
    if token.type is TokenType.START_TAG:
        events.append(("start", token.tag, token.attrs))
    elif token.type is TokenType.TEXT:
        events.append(("data", token.data))
    elif token.type is TokenType.END_TAG:
        events.append(("end", token.tag))
print(events)
[('start', 'p', [('class', 'x')]), ('data', 'Hi & bye'), ('end', 'p')]

If you liked html.parser’s callback shape and only want the WHATWG-correct tree behind it, turbohtml.saxparse.sax_parse() keeps that shape: subclass turbohtml.saxparse.SaxHandler, override the events you need, and the parser fires them on the constructed tree rather than the raw tags. The handle_* methods map onto SAX methods one-to-one:

html.parser override

SaxHandler override

handle_starttag(tag, attrs)

start_element(tag, attrs)

handle_endtag(tag)

end_element(tag)

handle_data(data)

characters(data)

handle_comment(data)

comment(data)

handle_decl(decl)

doctype(name, public_id, system_id)

handle_pi(data)

processing_instruction(data)

from turbohtml.saxparse import SaxHandler, sax_parse


class Collector(SaxHandler):
    def __init__(self):
        self.starts = []

    def start_element(self, tag, attrs):
        self.starts.append(tag if not attrs else f"{tag} {dict(attrs)}")


collector = Collector()
sax_parse("<table><td>cell", collector)
print(collector.starts)
['html', 'head', 'body', 'table', 'tbody', 'tr', 'td']

html.parser would report just table and td; the SAX events carry the implied html/head/body and the foster-parented tbody/tr the tree builder inserts. Unlike html.parser, sax_parse builds the working tree (freed at the end) rather than streaming in constant space, so it is the tool for a spec-correct one-pass extraction, not for a document larger than memory. The Parse with SAX callbacks guide has more, and The event-driven parse covers the memory model.

Unicode normalization

unicodedata.normalize() and unicodedata.is_normalized() move to turbohtml.detect.normalize() and turbohtml.detect.is_normalized(): the form name comes first and the output is identical, because turbohtml runs the four forms in C over tables generated from the interpreter’s own unicodedata. A quick check returns already-normalized text untouched.

stdlib call

turbohtml call

unicodedata.normalize("NFC", s)

turbohtml.detect.normalize("NFC", s)

unicodedata.is_normalized("NFC", s)

turbohtml.detect.is_normalized("NFC", s)

import unicodedata

from turbohtml.detect import normalize

forms = ("NFC", "NFD", "NFKC", "NFKD")
text = "fi café ẛ̣"
print(all(normalize(form, text) == unicodedata.normalize(form, text) for form in forms))
True

Gotchas and pitfalls

  • The token stream inverts html.parser’s callback control flow: you loop over tokens and branch on Token.type instead of overriding handle_* (unless you subclass turbohtml.migration.stdlib.HTMLParser, which keeps the callbacks).

  • By default token.data already holds decoded text (the equivalent of convert_charrefs=True). To recover the split stream convert_charrefs=False gives, pass resolve_references=False and handle TokenType.CHARACTER_REFERENCE tokens, whose token.source is the verbatim reference and token.data its resolved value. On turbohtml.migration.stdlib.HTMLParser the convert_charrefs argument is accepted for signature compatibility but ignored; references are always resolved.

  • The verbatim start-tag text get_starttag_text() returns is token.source once you pass capture_source=True; it is not captured by default.

  • html.parser is documented as not fully HTML5-conformant, so tricky recovery cases (malformed tags, misnested elements, foreign content) can tokenize differently. turbohtml follows the WHATWG spec, so output may diverge from a legacy html.parser run on the same broken input; the turbohtml result is the conformant one.

  • If your code imports the reference tables from html.entities directly, keep that import: turbohtml does not re-export name2codepoint/codepoint2name/html5.