Alle paar Monate taucht ein schnellerer HTML-Parser auf, die Benchmark-Runden machen die Runde, und irgendwer erklärt die alte Garde für überholt. Spätestens dann, wenn du alle Absätze auswählen willst, die ein bestimmtes Wort enthalten, oder den Parent eines passenden Knotens brauchst, fällt dir wieder ein, warum lxml in deinem anderen Tab immer noch offen ist.
lxml ist ein 20 Jahre altes Binding für libxml2. Es ist nicht besonders glamourös. Es ist nicht neu. Und für einen ganz bestimmten Job — alles, was echtes XPath braucht — spielt in der üblichen Python-Landschaft schlicht niemand auf demselben Level mit. Das hier ist ein praxisnaher Test: was lxml kann, wo es still und leise gewinnt und an welchen Stellen dich seine Standardwerte beißen, wenn du sie nicht kennst.
lxml in einem Satz: Was es wirklich ist
lxml ist ein Python-Binding für die C-Bibliotheken libxml2 und libxslt. Es ist Parser und Serializer, aber kein Scraper und kein Browser — es verwandelt Markup in einen Baum, den du abfragen und verändern kannst, und wandelt diesen Baum wieder in Bytes zurück. Du bekommst eine API, die mit ElementTree kompatibel ist, eine vollständige XPath 1.0-Engine, XSLT 1.0 und Schema-Validierung. Gepflegt wird das Projekt von Stefan Behnel mit dem Anspruch, die "funktionsreichste und am einfachsten zu nutzende Bibliothek zur Verarbeitung von XML und HTML in Python" zu sein.
Der Stand auf Basis eines GitHub- und PyPI-Snapshots vom 2026-07-14:
| Feld | Wert |
|---|---|
| Repo | lxml/lxml |
| Stars | 3,043 |
| Forks | 620 |
| Offene Issues | 16 |
| Lizenz | BSD-3-Clause |
| Erstellt | 2011-02-11 |
| Letzter Push | 2026-07-02 |
| PyPI stable | 6.1.1 (2026-05-18) |
| Gebündelte Engine | libxml2 2.14.6 + libxslt 1.1.43 |
Bevor mir jemand Hype unterstellt, gleich vorweg: In diesem Test steckt kein Geheimwissen. lxml ist alt genug, dass sich jedes Verhalten irgendwo in den lxml-Dokumenten, einem libxml2-Changelog oder einem Launchpad-Thread wiederfindet. Ich habe keine exklusive, undocumented Spezialfunktion gefunden — und werde auch keine erfinden. Der Wert der folgenden Analyse liegt darin, dass sie systematisch, quantifiziert und auf lxml als Thema zugeschnitten ist, nicht darin, dass sie brandneu wäre.
Testaufbau (und warum die Timing-Werte übernommen sind)
In diesen Test fließen zwei Datenklassen ein, und sie stammen aus zwei verschiedenen Quellen. Deshalb sage ich gleich offen dazu, was was ist.
Die Funktionstests — XPath-Verhalten, die beiden Parser-APIs, Namespaces, Encoding, Knoten-Lifecycle — habe ich frisch auf einem Rechner ausgeführt: macOS arm64, Python 3.14.2, lxml 6.1.1, libxml2 2.14.6. Jede Zahl in den artifacts/raw/*.json-Dateien wurde per Script berechnet und nicht von Hand eingetragen. Funktionstests sind deterministische Booleans und Enums, also bleibt ein einzelner Lauf stabil — die Maschinenlast ändert nicht, ob //a/@href ein Attribut-String zurückgibt.
Die Timing- und Speicherwerte stammen nicht aus diesem Paket. Sie wurden unverändert aus dem früheren selectolax-Benchmark-Paket übernommen — gleiche Maschine, gleiche virtuelle Umgebung, derselbe lxml-/libxml2-Build, Benchmarks mit Stand 2026-07-13 — und ich habe sie hier nicht neu ausgeführt. Das ist Absicht. Timing-Benchmarks parallel zu einem Stapel Funktionstests laufen zu lassen, würde CPU-Konkurrenz erzeugen und die übernommenen Werte verfälschen. Außerdem wäre es doppelte Arbeit: lxml war in diesem Paket bereits eine vollständig vermessene Kontrollbibliothek. Die Wiederverwendung sorgt dafür, dass wir wirklich Äpfel mit Äpfeln vergleichen, statt eine zweite, leicht andere Messung einzuführen. Wenn du also unten einen Millisekundenwert siehst, lies ihn als „gleiches Test-Setup, Stand 2026-07-13“ — nicht als „heute neu gemessen“.
Die Ergebnisse tragen eine Vertrauensmarke: single-observation für die deterministischen Funktionstests, triple-run für die übernommenen Timing-Verteilungen und hypothesis, wenn ich einen Mechanismus vorschlage, den ich nicht separat isoliert habe.
XPath: Das eine Ding, das selectolax und BeautifulSoup einfach nicht haben
Das ist die Kernbotschaft, also fange ich genau hier an.

Ich habe lxmls xpath() gegen eine vorher registrierte Matrix mit 37 Fällen laufen lassen — das erwartete Ergebnis für jeden Fall stand schon im Quelltext, bevor der Test gestartet wurde. So konnte ich mir keine Wohlfühlbewertung einbauen. Zehn Achsen, neun Prädikatstypen, zehn eingebaute Funktionen, drei skalare Rückgabetypen und fünf bewusst eingebaute Fallen mit XPath-2.0-Syntax, die die 1.0-Engine von lxml ablehnen sollte.
| Kategorie | Abdeckung | Ergebnis |
|---|---|---|
| Achsen | child / descendant / parent / ancestor / following-sibling / preceding-sibling / self / attribute / following / preceding | 10/10 bestanden |
| Prädikate | [1] / last() / position()<n / Attributgleichheit / Attributexistenz / and / or / verschachtelt [.//a] / not() | 9/9 bestanden |
| Funktionen | text() / contains() / starts-with() / count() / string-length() / normalize-space() / concat() / substring() / name() / string() | 10/10 bestanden |
| Rückgabetypen | boolean / number scalars | 3/3 bestanden |
| Fallen | matches() / Sequenzen / if-then-else / except / Syntaxfehler | 5/5 korrekt abgelehnt |
Das Ergebnis ist 37/37, und die Spalte mit den Fallen ist die eigentlich spannende. matches(), Sequenz-Ausdrücke, if/then/else und except gehören allesamt zur XPath-2.0-Syntax, und die 1.0-Engine von libxml2 unterstützt sie nicht halbherzig, sondern wirft stattdessen XPathEvalError und lehnt sauber ab, statt stillschweigend ein falsches Node-Set zurückzugeben. Das ist also ein perfekter Score nach dem Versuch, das System zu brechen — nicht einer, der aus lauen Testfällen zusammengebaut wurde. Jedes hier beobachtete Verhalten entspricht exakt den lxml-XPath-Dokumenten, und genau das ist der Punkt.
Eine Sache, die der Harness zunächst falsch hatte, gestehe ich gern ein — und zwar die Art von 37/37, der man wirklich trauen kann. Mein erstes Expected-Set für //div[.//a[@href]] sagte zwei Treffer voraus; der Lauf lieferte einen. Ich hielt lxml für etwa dreißig Sekunden für falsch und prüfte dann das Fixture: Das zweite Element war ein <footer> und kein <div>. Nicht die Engine lag falsch, sondern meine Erwartung. Den Fehler habe ich korrigiert und im Quellkommentar stehen lassen. Das ist die richtige Reihenfolge der Schuldfrage: zuerst den eigenen Test verdächtigen, dann die 20 Jahre alte C-Bibliothek.
XPath vs. CSS: Was du in CSS buchstäblich nicht ausdrücken kannst
Die abstrakte Aussage „XPath ist mächtiger“ verdient eine konkrete Zahl, also habe ich die Lücke vermessen. lxml bietet dir sowohl .xpath() als auch .cssselect() (letzteres übersetzt CSS intern in XPath). Ich habe zehn Selektionsziele geprüft und geschaut, was sich mit CSS tatsächlich ausdrücken lässt.

| Ziel | XPath | CSS (cssselect) |
|---|---|---|
Nach Textinhalt filtern (contains(text(),"bargain")) | Ja | Kein Text-Prädikat |
Parent aus Kind selektieren (//b/parent::p) | Ja | Kein Parent-Selektor |
Einen Attributwert zurückgeben (//a/@href) | Ja | Nur Elemente |
Einen Textknoten zurückgeben (//p/text()) | Ja | Keine Textknoten |
Ancestor-Achse (//td/ancestor::div) | Ja | Keine Navigation nach oben |
Parent nach Kind-Anzahl filtern (//ul[count(li)=4]) | Ja | Kein Count-Prädikat |
Nach Textlänge filtern (string-length(text())>5) | Ja | Kein Längen-Prädikat |
nth-child / last-child / adjacent sibling | Ja | Ja (3 Basisfälle) |
Sieben von zehn Zielen haben überhaupt kein CSS-Äquivalent. Textfilter, Navigation nach oben zu Parents und Ancestors, das Herausziehen eines Attributwerts oder eines nackten Textknotens als Ergebnis, Zähl-Prädikate — CSS kann das alles nicht. Nur drei Fälle (nth-child, last-child, adjacent sibling) funktionieren in beiden Welten. Das ist die präzise Antwort auf die Frage, was du durch lxml wirklich gewinnst. selectolax ist CSS-only und hat überhaupt keine xpath()-Methode, also werden diese sieben Abfragetypen dort entweder zu mehrstufigen Python-Loops oder fallen ganz weg. Wenn deine Scraping-Logik auf einem davon beruht, ist die Entscheidung damit praktisch schon gefallen.
(Und ja, der Harness hat mich hier ein zweites Mal erwischt: Ich hatte für string-length(text())>5 ein leeres Set erwartet, aber zwei sechsstellige Strings haben gepasst. Erwartung korrigiert, Tool nicht.)
Drei Stufen der Strenge: etree vs. recover vs. lxml.html
XPath ist der Grund, lxml überhaupt zu nehmen. Die drei Stufen der Parsing-Strenge sind der Grund, es auch zu behalten.

Die meisten Parser geben dir ein einziges Verhalten für kaputte Eingaben. lxml gibt dir drei, und sie sind so verlässlich, dass ich sechs Klassen fehlerhaften Markups durch alle drei Varianten gejagt und vorher festgelegt habe, wie jeder Pfad reagieren soll.
| Fehlerhafte Eingabe | lxml.etree (streng) | etree + recover=True | lxml.html (fehlertolerant) |
|---|---|---|---|
Nicht geschlossenes Tag <root><a>x</root> | wirft | repariert | akzeptiert |
Falsch verschachtelt <b><i></b></i> | wirft | repariert | akzeptiert |
Undefinierte Entität | wirft | repariert | akzeptiert |
Blankes & (Tom & Jerry) | wirft | repariert | akzeptiert |
Mehrere Roots <a>1</a><b>2</b> | wirft | repariert | akzeptiert |
| Wohlgeformtes XML | akzeptiert | akzeptiert (0 Fehler) | akzeptiert |
Boolean-Attribut <input disabled> | wirft | repariert | akzeptiert |
Sieben von sieben Fällen entsprachen der vorab registrierten Erwartung. lxml.etree wirft bei allen sechs fehlerhaften Klassen XMLSyntaxError. Derselbe Parser mit recover=True schluckt die Fehler und baut einen nutzbaren Baum neu auf — und das ist der unterschätzte Teil — parser.error_log listet anschließend jeden einzelnen Fehler auf, den er verschluckt hat. lxml.html akzeptiert alles ohne Murren.
Die Klassifizierung „geworfen vs. repariert vs. akzeptiert“ wird dabei selbst über die Laufzeit-Länge von error_log gesteuert, nicht fest verdrahtet. Deshalb wird ein wohlgeformtes Dokument unter recover=True korrekt als „akzeptiert“ (leeres Log) und nicht als „repariert“ eingestuft. Meine erste Version des Klassifikators hat jedes recover=True-Ergebnis als „repariert“ markiert und saubere Eingaben falsch gelabelt; erst das tatsächliche error_log hat das geradegezogen.
Was dir das in der Praxis bringt: Strikte Validierung, wenn ein kaputter Feed laut scheitern soll — nimm lxml.etree. Schmutziges HTML aus der echten Welt, das du einfach irgendwie verarbeiten musst — nimm lxml.html. Und für den Mittelweg, den die meisten Tools nicht beherrschen — „sei tolerant, aber sag mir exakt, was kaputt war, damit ich es loggen kann“ — nimm recover=True und lies das Error-Log. selectolax hat die fehlertolerante Stufe, aber nichts darüber hinaus: keinen Striktmodus und kein Error-Log.
iterparse: Die Streaming-Stufe, die selectolax überhaupt nicht hat
Hier geht es um eine Funktionsfähigkeit, nicht um einen Geschwindigkeitsknopf. selectolax verarbeitet nur einen vollständigen String — es gibt keine inkrementelle Schnittstelle. lxmls iterparse liefert Elemente aus, sobald sie geschlossen werden, und zusammen mit dem klassischen fast_iter-Muster (elem.clear() und vorherige Geschwister löschen, während du vorgehst) bleibt der Speicherverbrauch flach, egal wie groß das Dokument wird.

Ich habe das Speicherverhalten direkt gemessen — Peak-RSS über ru_maxrss, jeder Kandidat in einem frischen Prozess, auf 300.000 <record>-Elementen mit insgesamt rund 26,7 MB (26.744.801 Bytes).
| Modus | Peak-RSS-Delta | Hinweise |
|---|---|---|
iterparse + clear (fast_iter) | ~1-2 MB | wird unterwegs freigegeben; flach unabhängig von der Menge |
iterparse ohne clear | ~386 MB | hält Referenzen; so schwer wie Full-Load |
etree.parse (Full-Load, Referenz) | ~386 MB | bewusst schwer; zeigt, dass das Messgerät die Größenordnung erkennt |
Der begrenzte Modus hält das Peak-RSS-Delta bei ungefähr 1–2 MB gegenüber rund 386 MB beim vollständigen Laden — also etwa 0,3–0,4 % der Größenordnung — und das erste Record-Event wird ausgelöst, noch bevor die Datei vollständig eingelesen ist. Das ist also wirklich inkrementell und kein Fake-Streaming. Die lehrreiche Zeile ist die mittlere: Lässt du im selben iterparse-Loop das clear() weg, steigt der Speicherverbrauch wieder auf ungefähr 386 MB, weil du Referenzen auf alles behältst. Der Gewinn steckt in clear(), nicht in iterparse allein. Dass der Full-Load-Referenzwert deutlich höher liegt als der begrenzte Modus, bestätigt außerdem, dass das RSS-Messgerät die Größenordnung tatsächlich sieht und nicht blind ins Leere misst. (Dieser Speichertest wurde in diesem Paket neu ausgeführt — er misst Footprint und ist damit von den übernommenen Timing-Werten zu unterscheiden.)
Die Praxisversion davon: Ein XML-Export im Multi-Gigabyte-Bereich, der nicht in den RAM passt, hat keinerlei selectolax-Pfad. Da bleibt nur lxmls Streaming-Parser — oder eine andere Sprache.
Namespaces: RSS, SVG und die Falle mit dem Default-Namespace
Zwölf Namespace-Fälle, darunter RSS über drei Namespaces hinweg, SVG mit Default-Namespace plus xlink und XML mit Default-Namespace. Alle zwölf bestanden.
lxml zieht //dc:creator/text() aus einem RSS-Feed exakt als ["Alice", "Bob"], löst //atom:link/@href und //content:encoded über drei separate Namespaces im selben Dokument auf, verarbeitet in SVGs zweitem Namespace //s:rect und //s:use/@xlink:href und arbeitet mit Clark-Notation {uri}local über QName sowie nsmap. Das ist dokumentiertes und gepflegtes Verhalten — und ein ganzer Bereich, den selectolax überhaupt nicht berührt, weil selectolax HTML5-only ist und keine beliebigen XML-Namespaces verarbeitet.
Es gibt allerdings eine dokumentierte Falle, die man sich merken sollte. XPath kennt kein Konzept eines Default-Namespaces. Wenn du //book auf ein Dokument mit xmlns="urn:..." anwendest, bekommst du null Treffer — das leere Präfix ist für XPath nicht definiert, wie die lxml-Dokumentation erklärt. Du musst entweder ein künstliches Präfix binden (//c:book mit namespaces={"c": "urn:..."}, was alle drei Treffer fand) oder auf //*[local-name()='book'] ausweichen (ebenfalls drei Treffer). Kein Bug — das ist die XPath-Spezifikation, korrekt umgesetzt. Es überrascht nur jeden genau einmal.
Echte schmutzige Seiten: Trefferqualität auf 11 realen Scrapes
Synthetische Tests sind sauber; das Web ist es nicht. Ich habe elf echte, aufgezeichnete Seiten aus dem Fixture-Set des selectolax-Pakets wiederverwendet (Stand 2026-07-10, nur lesbar) und lxml.html mit lxml als Fokus darauf angesetzt.
| Fixture | Größe | Links | von libxml2 reparierte Fehler | Striktes XML |
|---|---|---|---|---|
| news_bbc.html | 398 KB | 255 | 4 | ok |
| docs_mdn_array.html | 243 KB | 508 | 0 | wirft |
| wiki_scraping.html | 227 KB | 460 | 0 | wirft |
| gov_whitehouse.html | 289 KB | 154 | 0 | wirft |
| oldstyle_craigslist.html | 561 KB | 351 | 0 | wirft |
| forum_reddit.html | 129 KB | 318 | 0 | wirft |
| docs_python.html | 80 KB | 341 | 2 | wirft |
| ecommerce_books.html | 51 KB | 94 | 0 | wirft |
| news_hackernews.html | 35 KB | 229 | 0 | wirft |
| ecommerce_webscraper_allinone.html | 16 KB | 35 | 0 | wirft |
| spa_quotes_js.html | 6 KB | 5 | 0 | wirft |
Alle elf Seiten wurden mit lxml.html geparst, und die Link-, Überschrift- und Bildanzahlen stimmten auf allen elf Seiten mit den wiederverwendeten lxml-Werten aus dem selectolax-Paket überein — Cross-Check: true. Genau diese Übereinstimmung zeigt mir, dass die Wiederverwendung wirklich Äpfel mit Äpfeln vergleicht und nicht zwei verschiedene Messungen unter demselben Etikett versteckt.
Die Nebenbeobachtung: Der strikte XML-Parser hat bei zehn von elf Seiten einen Fehler ausgelöst. Echte Webseiten sind überwiegend nicht wohlgeformtes XML — genau deshalb existiert der HTML-Recover-Mode von libxml2, um sie einzusammeln. Die einzige Ausnahme war BBC News, gerendert mit Next.js und so wohlgeformt, dass sie die strikte XML-Parsing-Strecke überlebt hat. Nicht alles, was als „HTML“ bezeichnet wird, braucht also den Recovery-Pfad.
Ein Zählhinweis, bei dem man leicht stolpert: Bei docs_python.html ergab //a[@href] (Attributexistenz) 343 Treffer, während das selectolax-Paket mit if n.get("href") (wahrer Wert) auf 341 kam. Die zwei zusätzlichen Treffer sind leere href=""-Links. Das ist ein Unterschied in der Zählkonvention — Attribut existiert versus Attribut ist nicht leer — kein Unterschied im Verhalten von lxml. Wenn du leere hrefs beim Scrapen zählst oder nicht, ist das deine Filterentscheidung, nicht die des Parsers.
Die Tiefengrenze, die wie ein Bug aussieht (aber keiner ist)
Im selectolax-Paket war dokumentiert, dass lxml den tiefsten Inhalt bei 1.000- und 5.000-fach verschachteltem <div>-Markup verliert, und das wurde als „lxml verliert stillschweigend den tiefsten Inhalt“ beschrieben. Mich interessierte der Mechanismus, also habe ich den Standardparser gegen huge_tree=True getestet.

| Angeforderte Tiefe | Standardparser erreicht | huge_tree=True erreicht |
|---|---|---|
| 300 | 253 (Rest fällt weg) | 299 (wiederhergestellt) |
| 1000 | 253 (Rest fällt weg) | 999 (wiederhergestellt) |
| 5000 | 253 (Rest fällt weg) | 2045 (fällt weiter weg) |
Der Standardparser schneidet bei etwa 253 Ebenen ab und verwirft alles, was darunter liegt, stillschweigend. Das ist kein Bug, sondern eine DoS-Abwehr von libxml2: eine Nistgrenze von ungefähr 256 Ebenen, damit ein bösartiges Dokument nicht den Stack sprengt. Das ist im lxml-Launchpad-Thread zu XML_PARSE_HUGE dokumentiert. Setzt du huge_tree=True, kommen Tiefen von 300 und 1.000 vollständig zurück. Bei 5.000 Ebenen erreicht der Parser allerdings selbst mit huge_tree nur 2.045 — es gibt also eine zweite, härtere Rekursionsgrenze in libxml2 oberhalb der konfigurierbaren Grenze, und huge_tree hebt auch diese nicht auf.
Die konkrete Handlungsempfehlung ist deshalb klar: Wenn du tief verschachteltes Markup aus einer vertrauenswürdigen Quelle parsest, nimm lxml.html.HTMLParser(huge_tree=True). Was dieses Paket zusätzlich zur früheren Beobachtung liefert, ist der Mechanismus (Sicherheitsgrenze statt Datenkorruption), die Lösung (huge_tree) und die Tatsache, dass es noch eine zweite Grenze gibt, an die der Fix nicht herankommt.
DOM lesen und schreiben, Serialisierung, Encoding
lxml ist ein vollständiger Lese-/Schreibbaum und kein reiner Extraktor, und ich habe die Bearbeitungsfläche Fall für Fall geprüft. Alle acht DOM-Operationen bestanden: SubElement, insert, remove, replace, strip_tags (Tags entfernen, Text behalten), strip_elements (Tags und ihren Text entfernen), drop_tree (eine nur in lxml.html verfügbare Spezialfunktion) sowie das Text/Tail-Doppelmodell, das Einsteiger oft irritiert — in <p>head<b>bold</b>tail</p> ist p.text gleich "head", b.text gleich "bold" und b.tail gleich "tail".
Die Serialisierung bestand fünf von fünf Tests: tostring im XML- und HTML-Modus (HTML lässt Void-Elemente korrekt nicht selbstschließend), pretty_print, C14N-Kanonisierung (method="c14n", ebenfalls eine lxml-spezifische Funktion) und ein sauberer Roundtrip.
Beim Encoding trennt sich lxml still und leise von vielen anderen. Gib ihm Bytes in einem Nicht-UTF-8-Encoding — etwa "<p>café éè</p>".encode("latin-1") über lxml.html.fromstring — und es rekonstruiert café éè vollständig, ohne U+FFFD-Ersatzzeichen und ohne verlorene Bytes. Das entspricht direkt seiner Rolle als „saubere Referenz“ im selectolax-Paket, in dem derselbe Input von den anderen beiden Engines stillschweigend beschädigt wurde (Lexbor erzeugte Ersatzzeichen, Modest ließ Bytes weg). Die libxml2-gestützte Zeichensatz-Erkennung ist hier schlicht stabiler.
Die Kehrseite ist die Strenge bei der Art, wie du ein Encoding deklarierst. encoding="latin-1" in einer XML-Deklaration wirft XMLSyntaxError: Unsupported encoding: latin-1, während das IANA-kanonische encoding="ISO-8859-1" problemlos geparst wird und café zurückgibt. libxml2 akzeptiert nur kanonische Encodings und keine Aliasse — ein Detail, das schon in launchpad #613302 dokumentiert wurde. Nervig, wenn man es nicht weiß, trivial, sobald man es kennt.
Zum Schluss noch der Knoten-Lifecycle. Ich habe drei Stale-Handle-Szenarien in isolierten Subprozessen getestet (ein harter Crash würde als Nicht-Null-Exit sichtbar): einen Knoten nach dem Garbage-Collect seines Baums halten, einen Handle nach drop_tree() lesen und einen Knoten nach remove() verwenden. In keinem Fall gab es einen Segfault — lxml hält die Referenz eines Knotens auf seinen Baum am Leben, um Use-after-free zu verhindern. Dasselbe saubere Ergebnis bekam selectolax in diesem Test.
Geschwindigkeit und Speicher (übernommen, und ehrlich dazu)
Alles in diesem Abschnitt wurde aus dem selectolax-Paket übernommen, Stand 2026-07-13. Dieses Paket hat keine eigenen Timing-Werte erzeugt, und ich sage das lieber zweimal, als dass du denkst, ich hätte hier irgendetwas neu gemessen.
| Dimension | lxml-Wert | Einordnung |
|---|---|---|
| Reines Parse-p50 (10 MB) | 77.9 ms | etwa 33–34 % schneller als selectolax-Lexbor |
| Voller Parse + Extraktion p50 (1 MB / 10 MB) | 14.18 ms / 172.9 ms | bei kleinen Größen ungefähr gleichauf mit Lexbor |
| 100k-Knoten-CSS-Durchsatz | 3,002,646 Knoten/s | schnellste Klasse der drei C-Engines |
| 10 MB RSS-Delta | 128.9 MB | sparsamster der sechs Parser, rund 1,7x sparsamer als BeautifulSoup |
| Kaltstart beim Import | 14.1 ms | etwa 2,3x schneller als parsel-ähnliche Imports |
Die reinen Parse- und Durchsatzwerte sind stark, und lxml ist von den sechs gemessenen Parsern der speichersparendste. Beim Thema Threads braucht es allerdings eine Einschränkung. Die wiederverwendeten Daten zeigen für vier Threads nur einen Wall-Clock-Speedup von 1,21x, als nicht eindeutig markiert — aber das ist der Pfad mit gemeinsam genutztem Default-Parser. Die lxml-FAQ sagt ausdrücklich, dass der GIL beim Parsen nur dann freigegeben wird, wenn jeder Thread seinen eigenen Parser oder eine Kopie des Default-Parsers verwendet; ein geteilter Parser serialisiert den Zugriff. Dass die API das korrekt kann, habe ich strukturell verifiziert (XMLParser.copy() existiert, get/set_default_parser existieren, XPathEvaluator bringt ein internes Lock mit), aber ich habe den Speedup pro Thread-Parser nicht gemessen — dafür bräuchte es eine neue Timing-Messung, und dieses Paket erzeugt die nicht. Lies also „1,21x“ als „unter dem naiven Shared-Parser-Pfad“, nicht als Obergrenze für lxml-Threads.
Und noch ein Sternchen dazu: Das sind Einzelplattform-Werte, nämlich macOS arm64. Die Aussage, dass lxml beim reinen Parse Lexbor schlägt, läuft der üblichen Erwartung zuwider, wonach der Lexbor-Parser der schnellste ist. Das will deshalb unbedingt unter Linux x86_64 gegengeprüft werden, bevor jemand es als endgültig betrachtet.
Lizenzierung: Der langweilige Gewinn
lxml steht unter BSD-3-Clause, und die mitgelieferten C-Bibliotheken — libxml2 und libxslt — sind beide MIT-lizenziert. Das ergibt eine vollständig permissive Kette ohne Copyleft, was sofort wichtig wird, sobald du das Ganze weiterverteilest. Zum Vergleich: Das selectolax-Wheel bündelt LGPL-2.1-Modest und Apache-2.0-Lexbor. Für den Einsatz in einem Closed-Source-Produkt ist lxml deshalb die deutlich unkompliziertere Geschichte.
Auch beim Installieren gibt es einen praktischen Vorteil: lxml veröffentlicht vorgebaute Wheels, die libxml2 und libxslt statisch mitbringen. pip install lxml braucht daher in der Regel weder ein System-libxml2 noch einen Compiler auf deinem Rechner — ein anderer Komfort als beim Bauen aus dem Quellcode.
Wo lxml hingehört — und wo eine AI-Extraktionsschicht übernimmt
Hier muss die Grenze klar sein, denn hier liegt schnell ein Kategorienfehler vor. lxml ist eine Parsing-Bibliothek. Sie gibt dir einen Baum und eine hervorragende Query-Engine; alles rund um diesen Baum bleibt deine Aufgabe: die Seite abrufen, JavaScript rendern, an Anti-Bot-Schutz vorbeikommen, die XPath-Ausdrücke schreiben und pflegen und das Ergebnis strukturieren. Das ist eine andere Schicht als ein gehosteter Extraktionsdienst — die beiden sind weniger Rivalen als Nachbarn.
Für Entwickler, die Fetch-/Render-/Select-/Maintain-Stack lieber nicht selbst betreiben wollen, lebt die obere Schicht etwa in Thunderbit — und für dieses Publikum geht es dort um API, MCP-Server und CLI, nicht um die Browser-Erweiterung. Die Thunderbit Open API stellt POST /distill bereit, um eine Seite in sauberes Markdown zu verwandeln, und POST /extract, um strukturierte Daten anhand eines JSON-Schemas zu ziehen — inklusive renderMode und Batch-Jobs für größere Mengen. Dieselbe Engine gibt es auch als MCP-Server (thunderbit_suggest_fields, thunderbit_distill, thunderbit_extract) für Agents und Coding-Assistenten sowie als CLI, das du direkt im Terminal via npx @thunderbit/thunderbit-cli ausführen kannst. Es kümmert sich um JS-Rendering, Anti-Bot und CAPTCHAs out of the box und liefert Schema-konformes JSON — also die Schicht über dem Parsing, nicht dessen Ersatz.
Thunderbit für Web-Datenextraktion testen
Die Einordnung ist simpel: Nimm lxml, wenn du den Pipeline-Teil selbst kontrollierst und ein präzises XPath-Handling für einen Baum brauchst, den du verstehst. Nimm eine KI-Extraktions-API, wenn du Selektoren und Rendering gar nicht erst pflegen willst. In vielen realen Systemen kommen beide zusammen zum Einsatz — lxml für die strukturierten Feeds, die man unter Kontrolle hat, und ein Extraktionsdienst für die unordentlichen Long-Tail-Seiten, die man nicht kontrolliert.
Was dieser Test nicht geprüft hat
Das hier ist ein vorläufiger Test und keine endgültige Endabrechnung, also hier die Lücken.
Alle Timing- und Speicherwerte wurden übernommen, stammen von einer einzelnen Plattform (macOS arm64, Python 3.14) und tragen die damit verbundenen Einschränkungen — das Ergebnis „lxml ist beim reinen Parsen schneller“ widerspricht dem üblichen Konsens und braucht eine erneute Prüfung auf Linux x86_64. Der Speedup beim Multithreading mit per-Thread-Parsern wurde nicht getestet (dafür wäre eine neue Timing-Messung nötig). Ich habe das Speicherverhalten von iterparse auf 300.000 Records gemessen, aber nicht auf realem XML im Gigabyte-Bereich, nicht iterparse auf HTML versus XML und keinen mehrstündigen Dauertest. lxmls XSLT 1.0, RelaxNG / XMLSchema / DTD-Validierung und EXSLT-Erweiterungen habe ich hier gar nicht untersucht — ein großer Funktionsbereich, aber außerhalb des Parsing- und Selektionskerns. Die zweite Tiefengrenze bei 2.045 habe ich beobachtet, aber die exakte Rekursionskonstante von libxml2 nicht bestimmt. Getestet wurde nur die stabile Version 6.1.1, nicht die Alpha 7.0.0. Windows, Source-Builds und der free-threaded Build 3.14t wurden nicht geprüft. Und innerhalb von XPath habe ich eingebaute Funktionen abgedeckt, aber keine XPath-Variablen, keine eigenen Python-Extensions und keine Wiederverwendung vorab kompilierter etree.XPath-Objekte.
Fazit
lxml ist nicht das neue, schnelle Ding — und genau das ist die Empfehlung. Es ist ein zwei Jahrzehnte altes libxml2-Binding mit einer vollständigen XPath-1.0-Engine, an die keine gängige Python-Alternative herankommt, mit drei vorhersehbaren Stufen der Parsing-Strenge inklusive Error-Log in der Mitte, einem echten Streaming-Parser für Dokumente, die nicht in den Speicher passen, sauberer Unterstützung für mehrere Namespaces und Encodings sowie einer vollständig permissiven Lizenz. Die paar scharfen Kanten — die Tiefengrenze bei rund 253 Ebenen und die Kennzahl beim Shared-Parser-Threading — sind dokumentiert, konfigurierbar und jetzt erklärt.
Wenn du deine Scraping-Pipeline selbst verantwortest und auf XPath setzt, ist lxml immer noch der Parser, zu dem du greifst. Wenn du Selektoren und Rendering lieber gar nicht pflegen willst, ist genau dafür eine KI-Extraktionsschicht wie die Thunderbit API, der MCP-Server und die CLI da — klare Arbeitsteilung statt Konkurrenz. In beiden Fällen gilt: Betrachte diese Zahlen als vorläufig und prüfe die Timings auf deiner eigenen Plattform noch einmal nach, bevor du sie in ein Design-Dokument schreibst.
Thunderbit für Web-Datenextraktion testen Get Started Free
FAQs
Ist lxml ein Web-Scraper?
Nein. lxml ist ein Parser und Serializer — ein Python-Binding für libxml2/libxslt, das Markup in einen bearbeitbaren, abfragbaren Baum verwandelt. Es ruft keine Seiten ab, rendert kein JavaScript und kümmert sich nicht um Anti-Bot-Schutz; diese Request-Schicht lieferst du selbst, etwa über requests, httpx, einen Headless Browser oder einen Scraping-Dienst, und übergibst dann die Bytes an lxml.
Wann sollte ich lxml statt BeautifulSoup oder selectolax verwenden? Greif zu lxml, wenn du XPath brauchst. BeautifulSoup kann zwar lxml als Parser-Backend nutzen, bietet aber kein natives XPath. selectolax ist CSS-only und in seiner Nische schneller. Wenn deine Selektionslogik Textfilter, Parent- oder Ancestor-Navigation, Attribut-/Textknoten-Extraktion oder Count-Prädikate braucht, ist lxmls XPath-Engine die einzige gängige Python-Option, die das direkt ausdrücken kann.
Warum verwirft lxml tief verschachtelten Inhalt stillschweigend?
Der Standardparser begrenzt die Verschachtelung auf etwa 253 Ebenen — eine DoS-Abwehr von libxml2 gegen bösartige Dokumente, kein Bug. Setze huge_tree=True (zum Beispiel lxml.html.HTMLParser(huge_tree=True)), dann werden Tiefen von 300 und 1.000 wieder vollständig verarbeitet. Beachte aber die zweite, härtere Rekursionsgrenze um 2.045 Ebenen, die huge_tree nicht aufhebt.
Gibt lxml beim Parsing im Multithreading den GIL frei? Nur unter den richtigen Bedingungen. Laut lxml-FAQ wird der GIL beim Parsen nur dann freigegeben, wenn jeder Thread einen eigenen Parser oder eine Kopie des Default-Parsers verwendet; ein geteilter Parser serialisiert den Zugriff. Der wiederverwendete Speedup von 1,21x für vier Threads spiegelt den naiven Shared-Parser-Pfad wider, nicht das Potenzial mit eigenen Parsern pro Thread, das hier nicht gemessen wurde.
Wird lxml auch 2026 noch aktiv gepflegt? Ja. Die stabile Version 6.1.1 erschien am 2026-05-18, das Repository erhielt zuletzt am 2026-07-02 einen Push, und eine Alpha 7.0.0 ist in Arbeit. Mit rund 3.000 GitHub-Stars und einem aktiv gepflegten libxml2 darunter ist es eine aktuelle, gut unterstützte Bibliothek und kein reines Legacy-Produkt.


