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

56.8 µs

130 µs (2.3x)

build a list (constructors) — 1k rows

543 µs

1.32 ms (2.5x)

build a list (constructors) — 10k rows

5.34 ms

13.2 ms (2.5x)

build a list (terse builders) — 100 rows

117 µs

198 µs (1.7x)

build a list (terse builders) — 1k rows

1.18 ms

1.95 ms (1.7x)

build a list (terse builders) — 10k rows

14.3 ms

20.6 ms (1.5x)

construct N elements (no serialize) — 100 rows

45 µs

90.1 µs (2.1x)

construct N elements (no serialize) — 1k rows

482 µs

952 µs (2.0x)

construct N elements (no serialize) — 10k rows

4.42 ms

9.38 ms (2.2x)

emit a built tree — 100 rows

4.22 µs

33.7 µs (8.0x)

emit a built tree — 1k rows

42.2 µs

320 µs (7.6x)

emit a built tree — 10k rows

420 µs

3.26 ms (7.8x)

parse to a tree — wpt tiny (0.6 kB)

1.21 µs

3.45 µs (2.9x)

parse to a tree — wpt small (4 kB)

9.58 µs

27.8 µs (2.9x)

parse to a tree — wpt medium (9.6 kB)

24.1 µs

74.7 µs (3.2x)

parse to a tree — wpt large (92 kB)

209 µs

645 µs (3.1x)

parse to a tree — wpt CJK (124 kB)

408 µs

1.45 ms (3.6x)

parse to a tree — whatwg spec (235 kB)

400 µs

1.26 ms (3.2x)

parse XML to a tree — catalog XML (122 kB)

1.89 ms

609 µs (0.4x)

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

11 µs

34.5 µs (3.2x)

find every anchor — daring fireball (10 kB)

371 ns

4.96 µs (13.4x)

find every anchor — ars technica (56 kB)

817 ns

13.4 µs (16.5x)

find every anchor — mozilla blog (95 kB)

1.16 µs

21.1 µs (18.2x)

find every anchor — whatwg spec (235 kB)

1.36 µs

35.6 µs (26.3x)

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

610 ns

29.4 µs (48.2x)

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

1.47 µs

126 µs (85.9x)

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

2.1 µs

800 µs (381x)

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

1.78 µs

1.35 ms (757x)

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

254 ns

14.3 µs (56.6x)

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

1.24 µs

28.2 µs (22.7x)

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

8.99 µs

60.7 µs (6.8x)

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

5.8 µs

71 µs (12.3x)

find by text content — daring fireball (10 kB)

24.4 µs

43.3 µs (1.8x)

find by text content — ars technica (56 kB)

178 µs

237 µs (1.4x)

find by text content — mozilla blog (95 kB)

287 µs

451 µs (1.6x)

find by text content — whatwg spec (235 kB)

560 µs

1.22 ms (2.2x)

collect visible text — daring fireball (10 kB)

2.7 µs

3.34 µs (1.3x)

collect visible text — ars technica (56 kB)

13 µs

15.2 µs (1.2x)

collect visible text — mozilla blog (95 kB)

22.4 µs

25.4 µs (1.2x)

collect visible text — whatwg spec (235 kB)

75.4 µs

89.1 µs (1.2x)

serialize a parsed tree — daring fireball (10 kB)

7.02 µs

38.5 µs (5.5x)

serialize a parsed tree — ars technica (56 kB)

38.8 µs

195 µs (5.1x)

serialize a parsed tree — mozilla blog (95 kB)

76.3 µs

425 µs (5.6x)

serialize a parsed tree — whatwg spec (235 kB)

195 µs

805 µs (4.2x)

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

3.32 µs

10.8 µs (3.3x)

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

13.1 µs

27.6 µs (2.2x)

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

20.9 µs

41.1 µs (2.0x)

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

42.6 µs

62.2 µs (1.5x)

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

2.24 µs

46.6 µs (20.8x)

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

8.89 µs

127 µs (14.4x)

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

8.78 µs

160 µs (18.3x)

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

8.7 µs

196 µs (22.6x)

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

23.8 µs

83.3 µs (3.5x)

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

115 µs

453 µs (4.0x)

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

260 µs

997 µs (3.9x)

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

645 µs

1.96 ms (3.1x)

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

24.3 µs

86.8 µs (3.6x)

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

122 µs

450 µs (3.7x)

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

264 µs

998 µs (3.8x)

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

683 µs

2.05 ms (3.0x)

replace body inner HTML — daring fireball (10 kB)

2.12 µs

13.8 µs (6.5x)

replace body inner HTML — ars technica (56 kB)

8.69 µs

47.6 µs (5.5x)

replace body inner HTML — mozilla blog (95 kB)

14.4 µs

105 µs (7.4x)

replace body inner HTML — whatwg spec (235 kB)

40.1 µs

257 µs (6.5x)

replace body text — daring fireball (10 kB)

1.18 µs

9.46 µs (8.1x)

replace body text — ars technica (56 kB)

7.73 µs

41.2 µs (5.4x)

replace body text — mozilla blog (95 kB)

12.9 µs

103 µs (8.1x)

replace body text — whatwg spec (235 kB)

40.6 µs

248 µs (6.2x)

walk every descendant — daring fireball (10 kB)

3.26 µs

16.5 µs (5.1x)

walk every descendant — ars technica (56 kB)

13.4 µs

61.4 µs (4.6x)

walk every descendant — mozilla blog (95 kB)

28 µs

136 µs (4.9x)

walk every descendant — whatwg spec (235 kB)

96.8 µs

468 µs (4.9x)

extract every link — daring fireball (10 kB)

8.49 µs

175 µs (20.7x)

extract every link — ars technica (56 kB)

27.6 µs

560 µs (20.3x)

extract every link — mozilla blog (95 kB)

50.1 µs

1.19 ms (23.7x)

extract every link — whatwg spec (235 kB)

84.6 µs

4.05 ms (47.9x)

absolutize every link — daring fireball (10 kB)

82.6 µs

257 µs (3.2x)

absolutize every link — ars technica (56 kB)

212 µs

833 µs (4.0x)

absolutize every link — mozilla blog (95 kB)

482 µs

1.74 ms (3.7x)

absolutize every link — whatwg spec (235 kB)

270 µs

4.73 ms (17.6x)

rewrite every link — daring fireball (10 kB)

4.69 µs

154 µs (32.9x)

rewrite every link — ars technica (56 kB)

11.5 µs

582 µs (50.8x)

rewrite every link — mozilla blog (95 kB)

21.7 µs

1.22 ms (56.5x)

rewrite every link — whatwg spec (235 kB)

38.1 µs

4.29 ms (113x)

social-card extraction — head

1.79 µs

10.9 µs (6.1x)

social-card extraction — article 8 KiB

22.1 µs

79.3 µs (3.6x)

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

2.49 µs

12.2 µs (4.9x)

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

6.29 µs

29.9 µs (4.8x)

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

8.51 µs

43.8 µs (5.2x)

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

9.05 µs

74.1 µs (8.2x)

extract text per match — daring fireball (10 kB)

2.75 µs

37.6 µs (13.7x)

extract text per match — ars technica (56 kB)

6.39 µs

85.8 µs (13.5x)

extract text per match — mozilla blog (95 kB)

10.3 µs

124 µs (12.1x)

extract text per match — whatwg spec (235 kB)

10.8 µs

169 µs (15.7x)

extract URL hints — base_url / get_base_url

1.27 µs

4.86 µs (3.9x)

extract URL hints — meta_refresh / get_meta_refresh

1.29 µs

5.29 µs (4.2x)

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

86.9 µs

77 µs (0.9x)

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

374 µs

336 µs (0.9x)

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

801 µs

697 µs (0.9x)

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

2.5 ms

1.65 ms (0.7x)

css_path for every element — daring fireball (10 kB)

17.9 µs

103 µs (5.8x)

css_path for every element — ars technica (56 kB)

90.3 µs

571 µs (6.4x)

css_path for every element — mozilla blog (95 kB)

232 µs

1.41 ms (6.1x)

css_path for every element — whatwg spec (235 kB)

2.13 ms

5.75 ms (2.7x)

xpath_path for every element — daring fireball (10 kB)

17.7 µs

102 µs (5.8x)

xpath_path for every element — ars technica (56 kB)

93.1 µs

546 µs (5.9x)

xpath_path for every element — mozilla blog (95 kB)

264 µs

1.41 ms (5.4x)

xpath_path for every element — whatwg spec (235 kB)

2.08 ms

5.82 ms (2.9x)

XPath feature surface (9.6 kB) — //div

1.77 µs

11.3 µs (6.5x)

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

368 ns

3.88 µs (10.6x)

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

1.47 µs

9.78 µs (6.7x)

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

806 ns

6.13 µs (7.7x)

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

10.2 µs

9.9 µs (1.0x)

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

363 ns

4.07 µs (11.2x)

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

5.47 µs

13.4 µs (2.5x)

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

371 ns

2.39 µs (6.5x)

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

606 ns

3.16 µs (5.3x)

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

4.35 µs

16 µs (3.7x)

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

392 ns

2.37 µs (6.1x)

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

528 ns

4.06 µs (7.7x)

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

360 ns

7.07 µs (19.7x)

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

415 ns

4.08 µs (9.9x)

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

466 ns

2.56 µs (5.5x)

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

878 ns

3.09 µs (3.6x)

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

904 ns

3 µs (3.4x)

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

555 ns

2.9 µs (5.3x)

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

2.54 µs

4.58 µs (1.9x)

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

333 ns

2.66 µs (8.0x)

extract filtered page links — daring fireball (10 kB)

127 µs

170 µs (1.4x)

extract filtered page links — ars technica (56 kB)

310 µs

622 µs (2.1x)

extract filtered page links — mozilla blog (95 kB)

514 µs

1.32 ms (2.6x)

extract filtered page links — whatwg spec (235 kB)

889 µs

4.06 ms (4.6x)

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 nearly twenty 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)

libxml2 leads on raw XML throughput – it is a decade-tuned C parser, and the parse XML to a tree row shows it ahead on the catalog document. turbohtml’s XML mode trades that for 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.

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. And libxslt leads on transform throughput – it is a decade-tuned C engine, and the XSLT transform row reflects that; turbohtml trades the raw speed for a stylesheet processor that ships 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.