From lxml

lxml latest releaselxml supported Pythonslxml licenselxml monthly downloadslxml total downloadslxml GitHub starslxml last commit

lxml is the libxml2/libxslt binding that most Python HTML and XML processing has been built on. lxml.html parses documents into ElementTree-style elements with .text/.tail strings, and the wider stack adds XPath, XSLT, RelaxNG/DTD/XML-Schema validation, and C14N. It is the default reach for scraping, feed parsing, and XML pipelines because it wraps a fast, mature C library and exposes the full ElementTree API.

turbohtml covers the HTML side of that ground with a native C core of its own. turbohtml.parse() builds the WHATWG document tree libxml2’s HTML parser does not, returns a fully type annotated Document, and folds XPath 1.0 (with EXSLT), CSS selection, and the find/find_all grammar into one node API instead of separate findall/xpath/cssselect entry points, adds an XSLT 1.0 processor (turbohtml.transform), and validates XML against XSD 1.0 and RELAX NG schemas. It does not attempt generic XML pipelines; it targets browser-accurate HTML parsing and the query/edit/transform/validate/serialize surface around it.

turbohtml vs lxml

Dimension

turbohtml

lxml

Scope

WHATWG HTML5 parse, serialize, edit, CSS, XPath 1.0 + EXSLT, XSLT 1.0, link helpers

Generic XML and HTML via libxml2, plus XSLT, schema validation, C14N

Feature breadth

Browser-accurate HTML tree, one node API for XPath/CSS/find, XSLT 1.0, streaming parse, builder

Full ElementTree API, XPath 1.0, XSLT 1.0, DTD/RelaxNG/XML-Schema, iterparse

Performance

Parses two to four times faster than lxml; stays ahead across the operational surface

Mature libxml2 C core; streaming evaluation narrows on multi-megabyte inputs

Typing

Fully type annotated, ships stubs

Partial; relies on third-party stub packages

Dependencies

Self-contained native C extension

Bundles or links libxml2 and libxslt

Maintenance

Newer, WHATWG-spec-driven

Long-established, widely deployed, actively maintained

Feature overlap

These port one-to-one from lxml.html/lxml.etree to turbohtml:

  • Parsing a document (lxml.html.document_fromstring) and a fragment (lxml.html.fromstring).

  • Element identity and attributes: el.tag, el.get/el.set/el.attrib, the el.classes set operations.

  • Tree navigation: getparent, getnext, getprevious, iterdescendants, iterancestors, list(el).

  • Queries: findall and xpath (XPath 1.0), cssselect (CSS), precompiled etree.XPath objects, node-set $variable bindings, namespaces= prefix maps, and custom XPath callables.

  • The EXSLT re:, set:, str:, math:, and date: function namespaces.

  • Locator generation (getroottree().getpath), link iteration and rewriting (iterlinks, make_links_absolute, rewrite_links), source positions (sourceline), tree edits (drop_tag, drop_tree), the lxml.builder.E builder, and serialization (lxml.html.tostring).

What turbohtml adds

  • A WHATWG-conformant parse: malformed input lands in the same tree a browser builds, where libxml2’s HTML parser does not follow the HTML5 tree-construction algorithm.

  • One node API. XPath, CSS, and the find/find_all grammar are methods on every node rather than three separate extension entry points, and a callable or extensions= mapping that returns an Element is marshaled straight back into the evaluator’s node-set.

  • Built-in EXSLT. The re:, set:, str:, math:, and date: namespaces dispatch in the compiled-C XPath engine with no per-call registration; lxml has to register libexslt and re-resolve the namespace map on each evaluation.

  • The XPath 2.0 string convenience functions – ends-with, string-join, lower-case, upper-case, matches, and replace – resolve in the same dispatch. libxml2’s XPath 1.0 has none of these, so an expression ported from elementpath or htmlquery that leans on them runs unchanged rather than raising an unknown-function error.

  • css_path(), a unique CSS-selector locator, which lxml has no equivalent for.

  • Full type annotations and shipped stubs across the whole surface.

What lxml has that turbohtml does not

The wider libxml2 toolchain is a deliberate clean-break scope cut:

  • XSLT is at full 1.0 parity (see the transform section below): lxml.etree.XSLT ports to turbohtml.transform.Transform, which covers the whole XSLT 1.0 instruction set including xsl:import (pass the stylesheet’s base_url so its href resolves). The documented boundaries are locale-aware xsl:sort collation (sorting is Unicode-codepoint order), id() over DTD-declared IDs (no DTD layer), xsl:include and document() (no additional-file loading beyond xsl:import), and the libxslt/EXSLT extension-element surface.

  • Schema validation: etree.XMLSchema and etree.RelaxNG map to turbohtml.validate.XMLSchema and RelaxNG (below); DTD (etree.DTD) and Schematron have no equivalent.

  • DTD-declared entities and the wider infoset: turbohtml.parse_xml() (below) handles well-formed XML but resolves only the five predefined entities and numeric references; a document that relies on <!ENTITY> definitions stays with lxml.

  • C14N 2.0: canonicalize() implements the Canonical XML 1.0/1.1 and Exclusive family that XML signatures sign (see below); the later, separately specified C14N 2.0 (etree.canonicalize) is out of scope.

  • XPath is at parity, not a gap. Both are XPath 1.0 with EXSLT, and turbohtml adds the XPath 2.0 string convenience functions on top. The only pieces out of scope are the node-synthesizing str:tokenize/str:split, the implicit current-date date: forms, and full XPath 2.0 (sequences, types, FLWOR).

Validate against a schema

etree.XMLSchema(schema_doc).validate(tree) becomes turbohtml.validate.XMLSchema.validate(), and etree.RelaxNG becomes RelaxNG. Where lxml returns a bool and stashes the reasons on schema.error_log, turbohtml returns a ValidationResult whose errors tuple carries each violation with the /root/child path that located it:

from turbohtml import parse_xml
from turbohtml.validate import XMLSchema

schema = XMLSchema(
    '<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">'
    '<xs:element name="qty" type="xs:positiveInteger"/></xs:schema>'
)
result = schema.validate(parse_xml("<qty>0</qty>"))
print(result.valid, result.errors[0].path)
False /qty

Performance

turbohtml parses two to four times faster than lxml while matching a browser on malformed input, and stays ahead across the operational surface: fragment parsing, CSS selection, text and tree walks, the link helpers, XPath, and the node-path generators.

operation

turbohtml

lxml

build a list (constructors) — 100 rows

57.1 µs

137 µs (2.5x ±4%)

build a list (constructors) — 1k rows

544 µs

1.35 ms (2.5x ±3%)

build a list (constructors) — 10k rows

5.41 ms

13.4 ms (2.5x ±3%)

build a list (terse builders) — 100 rows

114 µs

198 µs (1.8x ±2%)

build a list (terse builders) — 1k rows

1.17 ms

2 ms (1.8x ±3%)

build a list (terse builders) — 10k rows

13.7 ms

20.3 ms (1.5x ±2%)

construct N elements (no serialize) — 100 rows

42.9 µs

96 µs (2.3x ±5%)

construct N elements (no serialize) — 1k rows

436 µs

955 µs (2.2x ±3%)

construct N elements (no serialize) — 10k rows

4.34 ms

9.76 ms (2.3x ±4%)

emit a built tree — 100 rows

4.33 µs

35.6 µs (8.3x ±2%)

emit a built tree — 1k rows

43 µs

347 µs (8.1x ±2%)

emit a built tree — 10k rows

453 µs

3.51 ms (7.8x ±5%)

parse to a tree — wpt tiny (0.6 kB)

1.53 µs

4.71 µs (3.1x ±5%)

parse to a tree — wpt small (4 kB)

12.2 µs

36.1 µs (3.0x ±3%)

parse to a tree — wpt medium (9.6 kB)

32.5 µs

98 µs (3.1x ±7%)

parse to a tree — wpt large (92 kB)

276 µs

862 µs (3.2x ±3%)

parse to a tree — wpt CJK (124 kB)

532 µs

1.89 ms (3.6x ±10%)

parse to a tree — whatwg spec (235 kB)

519 µs

1.68 ms (3.3x ±4%)

parse to a tree — common tags (13 kB)

78.5 µs

213 µs (2.8x ±4%)

parse XML to a tree — catalog XML

437 µs

666 µs (1.6x ±2%)

validate a document against an XSD schema — catalog XSD + doc

1.08 ms

480 µs (0.5x ±1%)

validate a document against an XSD schema — 1,024 global declarations

798 ns

1.05 µs (1.4x ±2%)

parse a fragment — table-row fragment (2 kB)

10.2 µs

37.5 µs (3.7x ±2%)

find every anchor — daring fireball (10 kB)

372 ns

5.1 µs (13.8x ±1%)

find every anchor — ars technica (56 kB)

811 ns

13.3 µs (16.4x ±3%)

find every anchor — mozilla blog (95 kB)

1.12 µs

20.1 µs (18.0x ±2%)

find every anchor — whatwg spec (235 kB)

1.3 µs

31.4 µs (24.3x ±3%)

select div a[href] — daring fireball (10 kB)

595 ns

29.3 µs (49.3x ±1%)

select div a[href] — ars technica (56 kB)

1.43 µs

126 µs (88.2x ±1%)

select div a[href] — mozilla blog (95 kB)

2.01 µs

803 µs (400x ±1%)

select div a[href] — whatwg spec (235 kB)

1.76 µs

1.37 ms (775x ±2%)

select div:has(a) — daring fireball (10 kB)

262 ns

14.6 µs (55.8x ±3%)

select div:has(a) — ars technica (56 kB)

1.26 µs

27.7 µs (22.0x ±2%)

select div:has(a) — mozilla blog (95 kB)

8.54 µs

60.5 µs (7.1x ±3%)

select div:has(a) — whatwg spec (235 kB)

5.54 µs

70.9 µs (12.9x ±9%)

find by text content — daring fireball (10 kB)

24.4 µs

44 µs (1.9x ±3%)

find by text content — ars technica (56 kB)

173 µs

239 µs (1.4x ±1%)

find by text content — mozilla blog (95 kB)

278 µs

450 µs (1.7x ±2%)

find by text content — whatwg spec (235 kB)

556 µs

1.24 ms (2.3x ±1%)

collect visible text — daring fireball (10 kB)

2.63 µs

3.43 µs (1.4x ±2%)

collect visible text — ars technica (56 kB)

13.5 µs

15.2 µs (1.2x ±2%)

collect visible text — mozilla blog (95 kB)

21.9 µs

25.4 µs (1.2x ±1%)

collect visible text — whatwg spec (235 kB)

83.7 µs

87.5 µs (1.1x ±2%)

serialize a parsed tree — daring fireball (10 kB)

6.52 µs

39.7 µs (6.1x ±2%)

serialize a parsed tree — ars technica (56 kB)

34.8 µs

195 µs (5.7x ±1%)

serialize a parsed tree — mozilla blog (95 kB)

67.4 µs

423 µs (6.3x ±2%)

serialize a parsed tree — whatwg spec (235 kB)

174 µs

808 µs (4.7x ±2%)

serialize a parsed tree to XML — daring fireball (10 kB)

9.06 µs

20.1 µs (2.3x ±3%)

serialize a parsed tree to XML — ars technica (56 kB)

43.4 µs

92.2 µs (2.2x ±4%)

serialize a parsed tree to XML — mozilla blog (95 kB)

77.8 µs

198 µs (2.6x ±2%)

serialize a parsed tree to XML — whatwg spec (235 kB)

203 µs

451 µs (2.3x ±1%)

canonicalize a parsed tree (c14n) — daring fireball (10 kB)

12.4 µs

50.9 µs (4.2x ±1%)

canonicalize a parsed tree (c14n) — ars technica (56 kB)

58.4 µs

253 µs (4.4x ±1%)

canonicalize a parsed tree (c14n) — mozilla blog (95 kB)

109 µs

488 µs (4.5x ±2%)

canonicalize a parsed tree (c14n) — whatwg spec (235 kB)

253 µs

1.15 ms (4.6x ±1%)

tag every link rel=nofollow — daring fireball (10 kB)

4.74 µs

10.9 µs (2.3x ±3%)

tag every link rel=nofollow — ars technica (56 kB)

16.6 µs

27.7 µs (1.7x ±4%)

tag every link rel=nofollow — mozilla blog (95 kB)

25.4 µs

58.7 µs (2.4x ±35%)

tag every link rel=nofollow — whatwg spec (235 kB)

51.4 µs

83.3 µs (1.7x ±13%)

class add/remove on every link — daring fireball (10 kB)

2.62 µs

45.7 µs (17.5x ±2%)

class add/remove on every link — ars technica (56 kB)

9.67 µs

124 µs (12.9x ±2%)

class add/remove on every link — mozilla blog (95 kB)

10.3 µs

161 µs (15.7x ±3%)

class add/remove on every link — whatwg spec (235 kB)

10.1 µs

190 µs (19.0x ±2%)

drop tags with content (remove) — daring fireball (10 kB)

23.2 µs

82.8 µs (3.6x ±1%)

drop tags with content (remove) — ars technica (56 kB)

112 µs

428 µs (3.9x ±2%)

drop tags with content (remove) — mozilla blog (95 kB)

252 µs

974 µs (3.9x ±2%)

drop tags with content (remove) — whatwg spec (235 kB)

650 µs

1.98 ms (3.1x ±2%)

unwrap tags keep content (strip_tags) — daring fireball (10 kB)

24.4 µs

87.7 µs (3.6x ±6%)

unwrap tags keep content (strip_tags) — ars technica (56 kB)

120 µs

452 µs (3.8x ±2%)

unwrap tags keep content (strip_tags) — mozilla blog (95 kB)

261 µs

1.08 ms (4.2x ±10%)

unwrap tags keep content (strip_tags) — whatwg spec (235 kB)

669 µs

2.06 ms (3.1x ±2%)

replace body inner HTML — daring fireball (10 kB)

2.12 µs

13.9 µs (6.6x ±2%)

replace body inner HTML — ars technica (56 kB)

7.8 µs

47.7 µs (6.2x ±6%)

replace body inner HTML — mozilla blog (95 kB)

11.7 µs

108 µs (9.3x ±2%)

replace body inner HTML — whatwg spec (235 kB)

35.3 µs

259 µs (7.4x ±2%)

replace body text — daring fireball (10 kB)

1.13 µs

9.08 µs (8.1x ±2%)

replace body text — ars technica (56 kB)

6.4 µs

43 µs (6.8x ±2%)

replace body text — mozilla blog (95 kB)

10.5 µs

102 µs (9.8x ±2%)

replace body text — whatwg spec (235 kB)

33 µs

254 µs (7.7x ±1%)

walk every descendant — daring fireball (10 kB)

3.37 µs

17.2 µs (5.1x ±2%)

walk every descendant — ars technica (56 kB)

13.3 µs

64.1 µs (4.9x ±1%)

walk every descendant — mozilla blog (95 kB)

28.5 µs

135 µs (4.8x ±1%)

walk every descendant — whatwg spec (235 kB)

97.5 µs

473 µs (4.9x ±1%)

extract every link — daring fireball (10 kB)

13.5 µs

314 µs (23.4x ±20%)

extract every link — ars technica (56 kB)

45.7 µs

993 µs (21.8x ±21%)

extract every link — mozilla blog (95 kB)

75.5 µs

1.98 ms (26.3x ±14%)

extract every link — whatwg spec (235 kB)

66.4 µs

5.67 ms (85.5x ±5%)

absolutize every link — daring fireball (10 kB)

39.4 µs

267 µs (6.8x ±7%)

absolutize every link — ars technica (56 kB)

91.8 µs

868 µs (9.5x ±2%)

absolutize every link — mozilla blog (95 kB)

157 µs

1.78 ms (11.4x ±2%)

absolutize every link — whatwg spec (235 kB)

224 µs

4.87 ms (21.8x ±2%)

rewrite every link — daring fireball (10 kB)

3 µs

159 µs (53.0x ±4%)

rewrite every link — ars technica (56 kB)

10.7 µs

610 µs (57.0x ±5%)

rewrite every link — mozilla blog (95 kB)

19.9 µs

1.54 ms (77.5x ±20%)

rewrite every link — whatwg spec (235 kB)

33.3 µs

4.36 ms (132x ±4%)

social-card extraction — head

1.83 µs

10.9 µs (6.0x ±2%)

social-card extraction — article 8 KiB

21.9 µs

79.6 µs (3.7x ±2%)

extract @href per match — daring fireball (10 kB)

3.57 µs

17.9 µs (5.1x ±7%)

extract @href per match — ars technica (56 kB)

9.06 µs

43.3 µs (4.8x ±4%)

extract @href per match — mozilla blog (95 kB)

12.5 µs

64.4 µs (5.2x ±4%)

extract @href per match — whatwg spec (235 kB)

14.4 µs

105 µs (7.3x ±12%)

extract text per match — daring fireball (10 kB)

3.44 µs

55.2 µs (16.1x ±6%)

extract text per match — ars technica (56 kB)

8.73 µs

119 µs (13.7x ±5%)

extract text per match — mozilla blog (95 kB)

14.6 µs

179 µs (12.3x ±17%)

extract text per match — whatwg spec (235 kB)

14.6 µs

226 µs (15.5x ±8%)

extract URL hints — base_url / get_base_url

1.17 µs

4.84 µs (4.2x ±3%)

extract URL hints — meta_refresh / get_meta_refresh

1.21 µs

5.42 µs (4.5x ±2%)

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

98.9 µs

268 µs (2.8x ±23%)

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

494 µs

1.18 ms (2.4x ±14%)

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

1.24 ms

2.09 ms (1.7x ±52%)

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

2.61 ms

4.46 ms (1.8x ±16%)

streaming rewrite a page (no tree) — daring fireball (10 kB)

57 µs

179 µs (3.2x ±3%)

streaming rewrite a page (no tree) — ars technica (56 kB)

242 µs

758 µs (3.2x ±7%)

streaming rewrite a page (no tree) — mozilla blog (95 kB)

446 µs

1.6 ms (3.6x ±3%)

streaming rewrite a page (no tree) — whatwg spec (235 kB)

907 µs

3.19 ms (3.6x ±4%)

css_path for every element — daring fireball (10 kB)

22.9 µs

143 µs (6.3x ±3%)

css_path for every element — ars technica (56 kB)

120 µs

760 µs (6.4x ±7%)

css_path for every element — mozilla blog (95 kB)

308 µs

1.94 ms (6.3x ±4%)

css_path for every element — whatwg spec (235 kB)

2.91 ms

8.96 ms (3.1x ±6%)

xpath_path for every element — daring fireball (10 kB)

24.1 µs

143 µs (6.0x ±6%)

xpath_path for every element — ars technica (56 kB)

124 µs

797 µs (6.5x ±9%)

xpath_path for every element — mozilla blog (95 kB)

345 µs

1.98 ms (5.8x ±5%)

xpath_path for every element — whatwg spec (235 kB)

2.96 ms

9.24 ms (3.2x ±9%)

XPath feature surface (9.6 kB) — //div

2.37 µs

16.1 µs (6.8x ±5%)

XPath feature surface (9.6 kB) — //a[@href]

504 ns

5.46 µs (10.9x ±9%)

XPath feature surface (9.6 kB) — //div//a[@href]

2.13 µs

13.9 µs (6.6x ±5%)

XPath feature surface (9.6 kB) — /html/body/div

1.06 µs

8.79 µs (8.4x ±7%)

XPath feature surface (9.6 kB) — //div//a[1]

13.3 µs

14 µs (1.1x ±5%)

XPath feature surface (9.6 kB) — //a[contains(@href, '/')]

496 ns

5.81 µs (11.8x ±7%)

XPath feature surface (9.6 kB) — //div[position() <= 3]

7.84 µs

19.1 µs (2.5x ±4%)

XPath feature surface (9.6 kB) — //a/ancestor::div

529 ns

3.31 µs (6.3x ±4%)

XPath feature surface (9.6 kB) — //a | //span

835 ns

4.41 µs (5.3x ±7%)

XPath feature surface (9.6 kB) — //*[local-name() = 'a']

6.36 µs

19.8 µs (3.2x ±5%)

XPath feature surface (9.6 kB) — count(//a)

532 ns

3.44 µs (6.5x ±4%)

XPath feature surface (9.6 kB) — //a[@href=$x] (variable)

681 ns

5.9 µs (8.7x ±5%)

XPath feature surface (9.6 kB) — //a[re:test(@href, …)] (EXSLT)

501 ns

7.59 µs (15.2x ±6%)

XPath feature surface (9.6 kB) — set:distinct(//a) (EXSLT)

588 ns

5.31 µs (9.1x ±3%)

XPath feature surface (9.6 kB) — //a/@href (smart_strings)

658 ns

3.3 µs (5.1x ±5%)

XPath feature surface (9.6 kB) — ext(//a) (extensions)

1.28 µs

4.11 µs (3.3x ±4%)

XPath feature surface (9.6 kB) — ext(//a)/@href (node-set extension)

1.3 µs

4.28 µs (3.3x ±5%)

XPath feature surface (9.6 kB) — //svg:rect (namespaces=)

792 ns

3.82 µs (4.9x ±6%)

XPath feature surface (9.6 kB) — $rows/div (node-set variable)

3.5 µs

6.7 µs (2.0x ±5%)

XPath feature surface (9.6 kB) — //a[@href] (precompiled, reused)

435 ns

3.61 µs (8.3x ±5%)

XSLT transform a catalog (120 rows) — catalog (120 rows)

308 µs

372 µs (1.3x ±8%)

extract filtered page links — daring fireball (10 kB)

144 µs

178 µs (1.3x ±4%)

extract filtered page links — ars technica (56 kB)

363 µs

659 µs (1.9x ±5%)

extract filtered page links — mozilla blog (95 kB)

619 µs

1.29 ms (2.1x ±7%)

extract filtered page links — whatwg spec (235 kB)

1.06 ms

2.19 ms (2.1x ±13%)

The Performance page benchmarks the full serializer, builder, editor, CSS, XPath 1.0, and EXSLT surface against lxml directly, and sweeps the node-path generators across every page size. Compiling a hot expression once with XPath (the parse happens at construction, so the call site only supplies the context node and any $name variables) stays ahead of lxml per evaluation, as the precompiled //a[@href] row shows. On the EXSLT cases, a re:test predicate runs over fourteen times ahead of lxml even though re: dispatches to Python’s re where lxml uses C libexslt, because it skips the per-call namespace resolution; lxml’s streaming evaluation narrows the node-set reductions on the multi-megabyte inputs.

Parsing XML

lxml.etree.fromstring / etree.XMLParser and turbohtml.parse_xml() both parse under XML 1.0 well-formedness rather than the HTML tree builder: names are case-sensitive, <x/> self-closes any element, and CDATA sections, processing instructions, and namespace prefixes are honored. The entry points swap directly, with two differences to plan for.

First, turbohtml keeps qualified names verbatim. lxml resolves a prefix to its URI and stores the tag in Clark notation ({urn:h}a, read back through etree.QName); turbohtml leaves the tag as the source h:a and keeps every xmlns/xmlns:prefix declaration as an ordinary attribute on attrs. It still validates namespaces – an undeclared prefix is a well-formedness error – but it does not build lxml’s nsmap or rewrite names.

Second, ill-formed input raises rather than recovering. A mismatched or unclosed tag, an undeclared prefix, an undefined entity, or a duplicate attribute raises HTMLParseError, whose error carries the ParseError code, line, and column – the equivalent of lxml’s default recover=False XMLSyntaxError. turbohtml has no recover=True counterpart; a document that must survive malformed input stays with lxml.

lxml

turbohtml

etree.fromstring(b"<r/>"), etree.XMLParser().feed(...)

turbohtml.parse_xml() (parse_xml("<r/>"))

etree.QName(el).localname, el.tag == "{urn}a", el.nsmap

el.tag is the source prefix:local; declarations stay on attrs

etree.XMLSyntaxError on malformed input

HTMLParseError (its error is a ParseError)

turbohtml holds its own on raw XML throughput – the parse XML to a tree row runs about 1.6 times faster than libxml2’s decade-tuned C parser on the catalog document, and gives the same native, fully typed, dependency-free node API its HTML path uses, so an XML feed and an HTML page navigate, query, and serialize through one surface. libxml2’s streaming evaluation still narrows on multi-megabyte inputs.

Transforming with XSLT

lxml.etree.XSLT compiles a parsed stylesheet into a callable; turbohtml.transform.Transform does the same. Both read the stylesheet as XML, hold the compiled form, and apply it to a source tree, so the port is mechanical: etree.XSLT(etree.parse(sheet)) becomes Transform(parse_xml(sheet)), and calling the result on a document returns the transformed markup as a str.

from turbohtml import parse_xml
from turbohtml.transform import Transform

style = parse_xml(
    '<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">'
    '<xsl:output method="html"/>'
    '<xsl:template match="/"><ul>'
    '<xsl:apply-templates select="catalog/book"><xsl:sort select="title"/></xsl:apply-templates>'
    "</ul></xsl:template>"
    '<xsl:template match="book"><li class="{@cat}"><xsl:value-of select="title"/></li></xsl:template>'
    "</xsl:stylesheet>"
)
convert = Transform(style)
source = parse_xml(
    '<catalog><book cat="sci"><title>Cosmos</title></book><book cat="fic"><title>1984</title></book></catalog>'
)
print(convert(source))
<ul><li class="fic">1984</li><li class="sci">Cosmos</li></ul>

A top-level xsl:param is a keyword argument whose value is an XPath expression, exactly as in transform(doc, param="'text'"). The engine reuses turbohtml’s XPath 1.0 evaluator for every match pattern and select expression and implements the whole XSLT 1.0 instruction set – templates with modes and priorities, apply-templates with sort, call-template, for-each, if, choose, value-of, copy/copy-of, element/ attribute, variable/param, multi-level number, key and key(), strip-space/ preserve-space, attribute-set, namespace-alias, fallback, simplified stylesheets, cdata-section-elements, and the xml/html/text output methods. xsl:import resolves against a base_url you pass alongside the source; the imported declarations enter conflict resolution at lower import precedence.

Two limits to plan for. Only xsl:import loads other files (pass base_url); xsl:include and document() load nothing, and locale-aware xsl:sort collation and id() over DTD-declared IDs are out of reach for want of a collation and DTD layer. On transform throughput turbohtml runs about 1.3 times faster than libxslt’s decade-tuned C engine on the XSLT transform row, and ships its stylesheet processor in the same pure, dependency-free wheel as the parser, over one typed node API. A pipeline that lives inside libxslt’s wider XSLT/EXSLT surface stays with lxml.

How to migrate

The two parse entry points swap directly: turbohtml.parse() replaces lxml.html.document_fromstring and turbohtml.parse_fragment() replaces lxml.html.fromstring. The biggest change is the tree shape. lxml stores text as an element’s .text and .tail strings; turbohtml models it as real child Text nodes, so you iterate children instead of reading two string fields.

lxml

turbohtml

el.tag

tag (same)

el.get("x"), el.attrib, el.set("x", "v")

attrs (attrs.get("x"), attrs["x"] = "v")

el.classes.add("x"), el.classes.discard("x"), el.classes.toggle("x"), "x" in el.classes

el.add_class("x"), el.remove_class("x"), el.toggle_class("x"), el.has_class("x")

el.text, el.tail

child Text nodes; iterate children

el.text_content()

text

el.getparent(), el.getnext(), el.getprevious()

parent, next_sibling, previous_sibling

list(el), el.iterdescendants(), el.iterancestors()

children, descendants, ancestors

el.findall(".//a"), el.xpath("//a[@href]")

find_all(), xpath()

etree.XPath("//a[@href=$u]")(el, u=v)

XPath (XPath("//a[@href=$u]")(el, u=v))

el.xpath("$rows/td", rows=el.xpath("//tr"))

el.xpath("$rows/td", rows=el.xpath("//tr")) (a $name variable binds a scalar, an Element, or an iterable of elements; xpath_one() and xpath_iter() take the same bindings)

el.xpath("//svg:rect", namespaces={"svg": SVG})

xpath() with the same namespaces={"svg": SVG} (the prefix binds at evaluation time)

el.cssselect("div a")

select()

etree.FunctionNamespace(None)["f"] = fn; el.xpath("f(//a)")

el.xpath("f(//a)", extensions={(None, "f"): fn}) (the function may return a scalar, an Element, or an iterable of elements)

el.getroottree().getpath(el)

el.xpath_path() (or el.css_path() for a CSS selector)

lxml.html.Element("div"), etree.SubElement(p, "div")

Element, p.append(Element("div"))

lxml.builder.E.ul(E.li("a"), E.li("b"))

turbohtml.build.E (E.<tag>(attrs, *children) with a leading attribute mapping)

el.drop_tag(), el.drop_tree()

unwrap(), decompose()

el.sourceline

source_line (1-based, like lxml; plus source_col)

el.iterlinks()

links()

el.make_links_absolute(base), el.rewrite_links(fn)

resolve_links(), rewrite_links()

etree.iterparse(...)

turbohtml.IncrementalParser (feed chunks, close for the Document)

lxml.html.tostring(el)

html

lxml.etree.tostring(el, method="xml"), tostring(el, method="xhtml")

el.serialize(Html(xml=True))

etree.tostring(el, method="c14n", exclusive=, with_comments=), ElementTree.write_c14n

el.canonicalize(Canonical(...))

A query-and-select flow ports directly:

doc = parse('<div><a href="/x">go</a></div>')
print(doc.find_all("a", attrs={"href": True}))
print(doc.select_one("div a").attrs["href"])
[Element('a')]
/x

Precompile a hot XPath the same way you would reach for lxml.etree.XPath over a bare el.xpath. turbohtml’s compiled program is tree-independent, so a single object evaluates against many documents:

from turbohtml import XPath

links = XPath("//a[@href=$u]")
doc = parse('<div><a href="/x">go</a><a href="/y">stay</a></div>')
print([link.attrs["href"] for link in links(doc, u="/x")])
['/x']

The builder reads like lxml.builder.E but hands back a real Element, so the query, edit, and serialize surface stays available on what you build:

from turbohtml.build import E

print(E.ul(E.li({"class": "item"}, "one"), E.li({"class": "item"}, "two")).serialize())
<ul><li class="item">one</li><li class="item">two</li></ul>

Where lxml reaches for tostring(el, method="xml") (or "xhtml") to emit well-formed XML, pass Html with xml=True. Empty elements self-close, foreign SVG and MathML subtrees carry their namespace declarations, and text and attribute values follow the XML escaping rules – the HTML void-element and raw-text special casing does not apply:

from turbohtml import Html

doc = parse("<p>a &amp; b<br><svg><rect></rect></svg></p>")
print(doc.select_one("p").serialize(Html(xml=True)))
<p>a &amp; b<br/><svg xmlns="http://www.w3.org/2000/svg"><rect/></svg></p>

Where lxml signs a document with etree.tostring(el, method="c14n") or ElementTree.write_c14n, pass Canonical to canonicalize(). It emits the same Canonical XML infoset – sorted attributes, minimized namespaces, empty elements as start-end pairs, normalized character references – as UTF-8 bytes, and takes the same exclusive, with_comments, and inclusive_ns_prefixes knobs (plus a version for c14n 1.0 vs 1.1):

from turbohtml import Canonical

doc = parse("<p z='1' a='2'>x &amp; y</p>")
print(doc.select_one("p").canonicalize())
print(doc.select_one("p").canonicalize(Canonical(exclusive=True, with_comments=True)))
b'<p a="2" z="1">x &amp; y</p>'
b'<p a="2" z="1">x &amp; y</p>'

Gotchas and pitfalls

  • No text/tail. A node’s children are its text runs and elements interleaved; read text for the concatenation.

  • Different tree on malformed input. lxml parses with libxml2, which is not WHATWG-conformant, so broken markup lands in a different tree than the one turbohtml (and a browser) builds. Do not expect byte-identical trees when porting scrapers that leaned on libxml2’s recovery quirks.

  • Custom XPath functions bind per call, not globally. lxml registers callables through etree.FunctionNamespace; turbohtml passes them through the extensions= mapping of xpath(), bound once against the compiled expression rather than a process-wide table.

  • Streaming differs. For a document that arrives in pieces, etree.iterparse is replaced by turbohtml.IncrementalParser: feed str or bytes chunks with feed and call close for the finished Document. It never holds the whole source at once, but it does not expose lxml’s event-driven element callbacks; you walk the completed tree.

  • EXSLT is built in but not exhaustive. The node-synthesizing str:tokenize/str:split and the implicit current-date date: forms stay out of scope; every other re:/set:/str:/math:/date: form ports straight through with no registration.