Alle paar Monate taucht ein schnellerer HTML-Parser auf, die Benchmark-Runden drehen sich, und plötzlich erklärt jemand die alte Garde für erledigt. Spätestens wenn du dann jeden Absatz auswählen willst, der ein bestimmtes Wort enthält, oder den Parent eines gefundenen Knotens brauchst, merkst du wieder, warum lxml in deinem anderen Tab noch immer offen ist.
lxml ist ein 20 Jahre alter Python-Binding für libxml2. Es ist weder spektakulär noch neu. Und genau bei einem Anwendungsfall — allem, was echtes XPath braucht — spielt es in der Mainstream-Python-Welt sonst kaum jemand auf demselben Niveau. Das hier ist ein praxisnaher Test: was lxml kann, wo es still und heimlich punktet und an welchen Stellen dich die Default-Einstellungen erwischen, wenn du sie nicht kennst.
lxml in einem Absatz: Was es wirklich ist
lxml ist ein Python-Binding für die C-Bibliotheken libxml2 und libxslt. Es ist ein Parser und Serializer, kein Scraper und kein Browser — es wandelt Markup in einen Baum um, den du abfragen und bearbeiten kannst, und verwandelt diesen Baum wieder in Bytes. Du bekommst eine ElementTree-kompatible API, eine vollständige XPath 1.0-Engine, XSLT 1.0 und Schema-Validierung. Betreut wird das Projekt von Stefan Behnel, mit dem Motto "the most feature-rich and easy-to-use library for processing XML and HTML in the Python language".
Hier 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 |
Eines vorweg, damit hier niemand Marketing vermutet: In diesem Review steckt kein Geheimwissen. lxml ist alt genug, dass sich jedes Verhalten irgendwo in den lxml-Dokumenten, in einem libxml2-Changelog oder in einem Launchpad-Thread nachlesen lässt. Ich habe keinen exklusiven, undokumentierten Trick gefunden — und ich erfinde auch keinen. Der Wert dieses Textes liegt in der Systematik: alles ist geordnet, messbar gemacht und auf lxml als Thema zugeschnitten.
Testaufbau (und warum die Timing-Werte geliehen sind)
In dieses Review fließen zwei Arten von Daten ein, und sie stammen aus zwei verschiedenen Quellen. Das sage ich bewusst offen.
Die Funktionstests — XPath-Verhalten, die beiden Parser-APIs, Namespaces, Encoding, Knoten-Lifecycle — habe ich frisch auf einer Maschine ausgeführt: macOS arm64, Python 3.14.2, lxml 6.1.1, libxml2 2.14.6. Alle Werte in den artifacts/raw/*.json-Dateien stammen aus einem Skriptlauf und wurden nicht von Hand eingetragen. Capability-Tests sind deterministische Booleans und Enums; ein einzelner Lauf reicht, weil Last auf der Maschine nicht ändert, ob //a/@href einen Attribut-String zurückgibt.
Die Timing- und Speicherwerte stammen nicht aus diesem Pack. Sie wurden unverändert aus dem früheren selectolax-Benchmark-Pack übernommen — gleiche Maschine, gleiche virtuelle Umgebung, gleicher lxml- und libxml2-Build, Benchmarks vom Stand 2026-07-13 — und hier nicht neu gemessen. Das ist Absicht. Timing-Benchmarks parallel zu einer Reihe von Capability-Skripten würden CPU-Contention erzeugen und die wiederverwendeten Zahlen verfälschen; außerdem wäre es doppelte Arbeit, denn lxml war in diesem Pack bereits vollständig vermessenes Kontrollwerkzeug. Durch die Wiederverwendung bleibt der Vergleich sauber und nicht subtil unterschiedlich. Wenn du unten also einen Millisekundenwert siehst, lies ihn als „gleiches Test-Setup, Stand 2026-07-13“ — nicht als „heute neu getimt“.
Die Befunde tragen einen Vertrauenswert: single-observation für die deterministischen Funktionstests, triple-run für wiederverwendete Timing-Verteilungen und hypothesis, wenn ich einen Mechanismus vermute, aber nicht isoliert habe.
XPath: Das eine, was selectolax und BeautifulSoup einfach nicht haben
Das ist die Kernbotschaft, also fange ich genau hier an.

Ich habe lxmls xpath() durch eine vorregistrierte 37-teilige Testmatrix gejagt — für jeden Fall stand das erwartete Ergebnis bereits im Quellcode, bevor der Test lief. So konnte ich nicht versehentlich großzügig bewerten. Zehn Achsen, neun Predicate-Stile, zehn eingebaute Funktionen, drei skalare Rückgabetypen und fünf absichtliche Stolperfallen 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 |
| Stolperfallen | matches() / Sequenzen / if-then-else / except / Syntaxfehler | 5/5 korrekt abgelehnt |
Das Ergebnis ist 37/37, und die Spalte mit den Stolperfallen ist die eigentliche Pointe. matches(), Sequenz-Ausdrücke, if/then/else und except sind XPath-2.0-Syntax, und die 1.0-Engine von libxml2 unterstützt sie nicht halbherzig, sondern lehnt sie mit XPathEvalError ab. Sie liefert also nicht stillschweigend ein falsches Node-Set zurück. Das ist also eine perfekte Punktzahl nach dem Versuch, es zu knacken — nicht nur eine schöne Statistik auf Basis harmloser Testfälle. Genau dieses Verhalten beschreibt auch die lxml-XPath-Doku, und darauf kommt es an.
Ich gestehe aber auch einen Fehler im Harness ein — und zwar den Teil von „37/37“, dem du wirklich trauen kannst. Mein erstes Expected Set für //div[.//a[@href]] ging von zwei Treffern aus; der Lauf lieferte nur einen. Ich dachte etwa dreißig Sekunden lang, lxml liege falsch, bis ich das Fixture prüfte und merkte: Das zweite Element war ein <footer> und kein <div> — also war meine Erwartung falsch, nicht die Engine. Ich habe den Expected Set korrigiert und den Fehler als Kommentar im Quellcode stehen lassen. Das ist die richtige Reihenfolge der Schuldfrage: erst die eigene Testlogik anzweifeln, 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“ braucht eine konkrete Zahl. Also habe ich die Lücke vermessen. lxml liefert sowohl .xpath() als auch .cssselect() (letzteres übersetzt CSS intern in XPath). Ich habe zehn Selektionsziele geprüft und festgehalten, welche davon sich mit CSS überhaupt ausdrücken lassen.

| 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 |
Attributwert zurückgeben (//a/@href) | Ja | Nur Elemente |
Textknoten zurückgeben (//p/text()) | Ja | Keine Textknoten |
Ancestor-Achse (//td/ancestor::div) | Ja | Keine Navigation nach oben |
Parent nach Anzahl Kinder 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 / benachbarter Geschwisterknoten | Ja | Ja (3 Basisfälle) |
Sieben von zehn Zielen haben gar kein CSS-Äquivalent. Filtern nach Textinhalt, nach oben zu Parents oder Ancestors navigieren, einen Attributwert oder einen nackten Textknoten als Ergebnis zurückgeben, Zähl-Prädikate — CSS kann all das nicht. Nur drei Fälle (nth-child, last-child, benachbarter Geschwisterknoten) funktionieren in beiden Welten. Das ist die konkrete Antwort auf die Frage: „Was gewinne ich eigentlich mit lxml?“ selectolax ist CSS-only und hat überhaupt keine xpath()-Methode, also werden diese sieben Abfragetypen dort zu mehrstufigen Python-Loops oder fallen ganz weg. Wenn deine Scraping-Logik darauf aufbaut, ist die Entscheidung damit praktisch schon gefallen.
(Und ja, das Harness hat mich hier ein zweites Mal erwischt: Für string-length(text())>5 hatte ich eine leere Menge erwartet, aber zwei sechsstellige Strings haben gematcht. Erwartung angepasst, Tool war korrekt.)
Drei Strenge-Stufen: etree vs. recover vs. lxml.html
XPath ist der Grund, lxml zu wählen. Die dreistufige Strenge ist der Grund, es zu behalten.

Die meisten Parser geben dir bei kaputten Eingaben nur ein Verhalten. lxml gibt dir drei — und sie sind so vorhersagbar, dass ich sechs Klassen fehlerhaftes Markup durch alle drei Modi gejagt und vorher festgelegt habe, wie jeder Pfad reagieren soll.
| Fehlerhafte Eingabe | lxml.etree (streng) | etree + recover=True | lxml.html (tolerant) |
|---|---|---|---|
Nicht geschlossenes Tag <root><a>x</root> | wirft | repariert | akzeptiert |
Falsch verschachtelt <b><i></b></i> | wirft | repariert | akzeptiert |
Undefinierte Entity | wirft | repariert | akzeptiert |
Blankes & (Tom & Jerry) | wirft | repariert | akzeptiert |
Mehrere Wurzeln <a>1</a><b>2</b> | wirft | repariert | akzeptiert |
| Wohlgeformtes XML | akzeptiert | akzeptiert (0 Fehler) | akzeptiert |
Boolesches Attribut <input disabled> | wirft | repariert | akzeptiert |
Sieben von sieben Fällen entsprachen der vorregistrierten Erwartung. lxml.etree wirft bei allen sechs fehlerhaften Klassen XMLSyntaxError. Wenn du denselben Parser mit recover=True ausführst, schluckt er die Fehler und baut einen nutzbaren Baum neu auf — und das ist der unterschätzte Teil — parser.error_log listet anschließend jeden Fehler auf, den er geschluckt hat. lxml.html akzeptiert alles kommentarlos.
Der Klassifizierer, der zwischen „wirft“, „repariert“ und „akzeptiert“ unterscheidet, hängt selbst von der Laufzeitlänge des error_log ab und nicht von fest einprogrammierten Regeln. Deshalb wird ein wohlgeformtes Dokument unter recover=True korrekt als „akzeptiert“ eingestuft (leeres Log) und nicht als „repariert“. Meine erste Version des Klassifizierers hatte jedes recover=True-Ergebnis als „repariert“ markiert und damit die saubere Eingabe falsch gelabelt; das Lesen des echten error_log hat das behoben.
Was dir das praktisch bringt: strenge Validierung, wenn ein kaputter Feed laut scheitern soll — dann nimm lxml.etree. Schmutziges HTML aus der echten Welt, das du einfach durchbekommen willst — dann nimm lxml.html. Und für den Mittelweg, den die meisten Tools nicht sauber können — „sei tolerant, aber sag mir genau, was kaputt war, damit ich es loggen kann“ — nimm recover=True und lies das Error-Log. selectolax hat nur den toleranten Modus und sonst nichts: weder strengen Modus noch Error-Log.
iterparse: Die Streaming-Funktion, die selectolax gar nicht hat
Hier geht es nicht um Geschwindigkeit, sondern um Fähigkeit. selectolax kann nur einen kompletten String einlesen — eine inkrementelle Schnittstelle gibt es nicht. lxmls iterparse liefert Elemente, sobald sie geschlossen werden, und zusammen mit dem klassischen fast_iter-Muster (also elem.clear() plus vorherige Geschwister löschen) bleibt der Speicherverbrauch konstant, egal wie groß das Dokument wird.

Ich habe das Speicherverhalten direkt gemessen — Peak-RSS über ru_maxrss, jede Variante in einem frischen Prozess, auf 300.000 <record>-Elementen mit insgesamt rund 15 MB.
| Modus | Peak-RSS-Delta | Hinweis |
|---|---|---|
iterparse + clear (fast_iter) | ~1-2 MB | wird laufend freigegeben; flach unabhängig von der Menge |
iterparse ohne clear | ~386 MB | hält Referenzen fest; so schwer wie ein Komplett-Load |
etree.parse (voller Load, Referenz) | ~386 MB | erwartbar schwer; zeigt, dass der Zähler die Größenordnung korrekt erkennt |
Der begrenzte Modus hält das Peak-RSS-Delta bei etwa 1-2 MB gegenüber rund 386 MB beim Voll-Load — also nur 0,3-0,4 % der Größenordnung — und das erste Record-Event feuert noch bevor die Datei komplett gelesen ist. Das ist echtes inkrementelles Streaming, kein Fake. Der lehrreichste Wert ist der mittlere: Derselbe iterparse-Loop ohne clear() lässt den Speicher wieder auf etwa 386 MB anwachsen, weil du alles referenzierst. Der Gewinn steckt also in clear(), nicht in iterparse allein. Dass der Voll-Load deutlich höher liegt als der begrenzte Modus, bestätigt außerdem, dass der RSS-Zähler den Größenunterschied wirklich sieht und nicht blind misst. (Dieser Speichertest stammt aus diesem Pack — es ist eine Footprint-Messung und damit etwas anderes als die geliehenen Timing-Werte.)
Die Praxisversion davon: Ein XML-Export im Multi-Gigabyte-Bereich, der nicht in den RAM passt, hat mit selectolax schlicht keinen gangbaren Weg. Dann bleibt nur lxmls Streaming-Parser oder eine andere Sprache.
Namespaces: RSS, SVG und die Default-Namespace-Falle
Zwölf Namespace-Fälle, darunter RSS über drei Namespaces, 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 getrennte Namespaces im selben Dokument auf, verarbeitet //s:rect und //s:use/@xlink:href in SVGs zweitem Namespace, zerlegt Clark-Notation {uri}local-Namen mit QName und erlaubt Inspektion via nsmap. Das ist dokumentiertes, gepflegtes Verhalten — und genau eine Dimension, die selectolax gar nicht abdeckt, 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 für einen Default-Namespace. Wenn du //book auf ein Dokument mit xmlns="urn:..." anwendest, bekommst du null Treffer — der leere Präfix ist für XPath undefiniert, wie die lxml-Dokumente beschreiben. Du musst entweder einen künstlichen 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. Überraschend ist es trotzdem, und zwar genau einmal.
Echte schmutzige Seiten: Treue auf 11 realen Scrapes
Synthetische Tests sind sauber; das Web nicht. Ich habe elf reale, gespeicherte Seiten aus dem Fixture-Set des selectolax-Packs wiederverwendet (Stand 2026-07-10, nur lesend) und lxml.html mit lxml als Untersuchungsobjekt darauf angesetzt.
| Fixture | Größe | Links | Von libxml2 reparierte Fehler | Strenges 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-, Überschriften- und Bildanzahlen stimmten auf allen elf Seiten mit den wiederverwendeten lxml-Zahlen aus dem selectolax-Pack überein — Cross-Check: true. Genau diese Übereinstimmung zeigt mir, dass die Wiederverwendung fair ist und nicht zwei verschiedene Messungen denselben Namen tragen.
Die Nebenbeobachtung: Der strenge XML-Parser ist bei zehn der elf Seiten gescheitert. Reale Webseiten sind überwiegend nicht wohlgeformtes XML — genau deshalb gibt es in libxml2 den HTML-Recovery-Modus. Die einzige Ausnahme war BBC News, offenbar von Next.js gerendert und gerade noch wohlgeformt genug, um strenges XML zu überleben. Nicht alles, was als „HTML“ etikettiert ist, braucht also automatisch den Recovery-Pfad.
Ein Zählhinweis, bei dem man leicht stolpert: Auf docs_python.html ergab //a[@href] (Attribut existiert) 343 Treffer, während im selectolax-Pack if n.get("href") (truthy Wert) nur 341 Treffer ergab. Die zwei zusätzlichen Treffer sind leere href=""-Links. Das ist ein Unterschied in der Zählkonvention — Attribut vorhanden versus Attribut nicht leer — kein Unterschied im Verhalten von lxml. Fürs Scraping ist das wichtig: Ob leere hrefs mitzählen, entscheidet dein Filter, nicht der Parser.
Die Tiefenbegrenzung, die wie ein Bug aussieht (aber keiner ist)
Im selectolax-Pack war dokumentiert, dass lxml bei 1.000 und 5.000 Ebenen tief verschachteltem <div>-Markup den tiefsten Inhalt verliert — formuliert als „lxml verliert stillschweigend den tiefsten Inhalt“. Ich wollte den Mechanismus verstehen und habe den Default-Parser gegen huge_tree=True getestet.

| Angeforderte Tiefe | Default-Parser 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 (immer noch abgeschnitten) |
Der Default-Parser schneidet bei rund 253 Ebenen ab und wirft alles darunter stillschweigend weg. Das ist kein Bug, sondern eine DoS-Schutzmaßnahme von libxml2: ein Nesten-Limit von ungefähr 256 Ebenen verhindert, dass ein bösartiges Dokument den Stack sprengt. Das ist auch im lxml-Launchpad-Thread zu XML_PARSE_HUGE dokumentiert. Mit huge_tree=True kommen die Tiefen 300 und 1.000 vollständig zurück. Bei 5.000 Ebenen erreicht der Parser selbst mit huge_tree jedoch nur 2.045 — oberhalb des konfigurierbaren Limits gibt es noch eine zweite, härtere Rekursionsgrenze in libxml2, und huge_tree hebt die nicht auf.
Die konkrete Konsequenz: Wenn du tief verschachteltes Markup aus einer vertrauenswürdigen Quelle parsest, nimm lxml.html.HTMLParser(huge_tree=True). Der Mehrwert dieses Packs gegenüber der wiederverwendeten Beobachtung ist der Mechanismus (Sicherheitsgrenze statt Datenverlust), die Lösung (huge_tree) und der Hinweis, dass es eine zweite, nicht entfernbare Grenze gibt.
DOM lesen und schreiben, Serialisierung, Encoding
lxml ist ein vollständiger Lese-/Schreibbaum und kein reiner Extraktor. Ich habe die Bearbeitungsoberflä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 lxml.html-Exklusivität) sowie das Text-/Tail-Dualslot-Modell, das Einsteiger oft stolpern lässt — in <p>head<b>bold</b>tail</p> ist p.text "head", b.text "bold" und b.tail "tail".
Die Serialisierung bestand fünf von fünf: tostring im XML- und HTML-Modus (HTML lässt Void-Elemente korrekt nicht selbstschließend), pretty_print, C14N-Kanonisierung (method="c14n", ebenfalls eine lxml-Exklusivität) und ein sauberer Roundtrip.
Beim Encoding trennt sich lxml unauffällig von der Masse. Gibst du ihm Nicht-UTF-8-Bytes — etwa "<p>café éè</p>".encode("latin-1") über lxml.html.fromstring — dann rekonstruiert es café éè unverfälscht, ohne U+FFFD-Ersatzzeichen und ohne verlorene Bytes. Das reproduziert direkt seine Rolle als „saubere Referenz“ im selectolax-Pack, wo derselbe Input bei den anderen beiden Engines still beschädigt wurde (Lexbor erzeugte Ersatzzeichen, Modest verwarf Bytes). lxmls auf libxml2 basierende Charset-Erkennung ist hier schlicht robuster.
Die Kehrseite ist 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" sauber geparst wird und café zurückgibt. libxml2 akzeptiert nur kanonische Encoding-Namen, keine Aliase — ein Detail, das schon in launchpad #613302 dokumentiert ist. Nervig, wenn man es nicht kennt; trivial, wenn man es weiß.
Zum Schluss noch der Knoten-Lifecycle. Ich habe drei Szenarien mit „verwaisten“ Handles in isolierten Subprozessen getestet (ein harter Crash hätte sich als nicht-null Exit gezeigt): einen Knoten nach der Garbage Collection seines Baums behalten, einen Handle nach drop_tree() lesen und einen Knoten nach remove() weiterverwenden. In keinem Fall gab es einen Segfault — lxml hält die Referenz eines Knotens auf seinen Baum am Leben und verhindert so Use-after-free. Diese saubere Bilanz bekam selectolax in demselben Test ebenfalls.
Geschwindigkeit und Speicher (geliehen, und offen dazu)
Alles in diesem Abschnitt stammt aus dem selectolax-Pack, Stand 2026-07-13. Dieses Pack erzeugt keine eigenen Timing-Werte, und ich sage das lieber zweimal, als dass du denkst, ich hätte irgendetwas neu getimt.
| Dimension | lxml-Wert | Einordnung |
|---|---|---|
| Pure Parse p50 (10 MB) | 77,9 ms | ca. 33-34 % schneller als selectolax-Lexbor |
| Voller Parse + Extract p50 (1 MB / 10 MB) | 14,18 ms / 172,9 ms | bei kleinen Größen ungefähr auf Augenhöhe mit Lexbor |
| 100k-Node CSS-Throughput | 3.002.646 Nodes/s | schnellste Liga der drei C-Engines |
| 10-MB-RSS-Delta | 128,9 MB | sparsamster von sechs Parsern, ca. 1,7x schlanker als BeautifulSoup |
| Kaltstart beim Import | 14,1 ms | ca. 2,3x schneller als parsel-artige Imports |
Die Pure-Parse- und Throughput-Werte sind stark, und lxml ist von den sechs gemessenen Parsern der sparsamste im Speicher. Bei Threads braucht das Bild aber eine Einschränkung. Die wiederverwendeten Daten zeigen für vier Threads nur einen Wall-Clock-Speedup von 1,21x, also ohne klare Aussage — allerdings auf dem Pfad mit gemeinsam genutztem Default-Parser. Die lxml-FAQ stellt ausdrücklich klar, dass der GIL beim Parsen nur dann freigegeben wird, wenn jeder Thread seinen eigenen Parser nutzt (oder eine Kopie des Default-Parsers); ein geteilter Parser serialisiert den Zugriff. Ich habe die API-Oberfläche für den korrekten Weg strukturell überprüft (XMLParser.copy() existiert, get/set_default_parser existieren, XPathEvaluator bringt ein internes Lock mit), aber ich habe den Speedup pro Thread-Parser nicht gemessen — das wäre eine neue Timing-Messung, und dieses Pack erzeugt die nicht. Lies die 1,21x also als Wert auf dem naiven Shared-Parser-Pfad, nicht als Thread-Grenze von lxml.
Und ein Sternchen gehört zu allen Zahlen: Das sind Single-Platform-Werte auf macOS arm64. Die Aussage, dass lxml beim reinen Parsen schneller als Lexbor ist, läuft der üblichen Erwartung zuwider, wonach der Lexbor-basierte Parser der schnellste ist. Das sollte also unbedingt auf Linux x86_64 gegengeprüft werden, bevor irgendjemand das als endgültig behandelt.
Lizenz: Der langweilige, aber echte Gewinn
lxml wird unter BSD-3-Clause ausgeliefert, und die eingebetteten C-Bibliotheken — libxml2 und libxslt — stehen beide unter MIT. Das ist eine durchgehend permissive Kette ohne Copyleft an irgendeiner Stelle, und genau das wird wichtig, sobald du etwas weitergibst. Zum Vergleich: Das selectolax-Wheel bündelt LGPL-2.1-Modest und Apache-2.0-Lexbor. Für ein geschlossenes Produkt ist lxml daher die deutlich unkompliziertere Geschichte.
Es gibt auch einen praktischen Installationsvorteil: lxml veröffentlicht vorgebaute Wheels, die libxml2 und libxslt statisch mitbringen. pip install lxml braucht deshalb in der Regel weder ein System-libxml2 noch einen Compiler auf deinem Rechner — ganz anders als ein Build aus dem Quellcode.
Wo lxml hingehört — und wo eine AI-Extraktionsschicht übernimmt
Hier lohnt sich eine klare Grenze, weil die Verwechslung schnell passiert. lxml ist eine Parsing-Bibliothek. Sie liefert dir einen Baum und eine hervorragende Query-Engine; alles um diesen Baum herum bleibt dein Job: die Seite abrufen, JavaScript rendern, Anti-Bot-Hürden umgehen, die XPath-Logik schreiben und pflegen, und das Ergebnis strukturieren. Das ist eine andere Schicht als ein gehosteter Extraktionsdienst — nicht Rivalen, eher Nachbarn.
Für Entwickler, die Fetch-/Render-/Select-/Maintain-Stack nicht selbst tragen wollen, beginnt genau dort eine Lösung wie Thunderbit — und für diese Zielgruppe sind API, MCP-Server und CLI interessanter als 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 Schema zu extrahieren. Dazu gibt es einen renderMode-Schalter und Batch-Jobs für größere Mengen. Dieselbe Engine steht außerdem als MCP-Server (thunderbit_suggest_fields, thunderbit_distill, thunderbit_extract) für Agents und Coding-Assistenten zur Verfügung sowie als CLI, das du direkt im Terminal über npx @thunderbit/thunderbit-cli starten kannst. Es übernimmt JS-Rendering, Anti-Bot und CAPTCHAs out of the box und gibt schema-konformes JSON zurück — also die Schicht über dem Parsen, nicht den Ersatz dafür.
Thunderbit für Web-Datenextraktion testen
Die Einordnung ist einfach: Greif zu lxml, wenn du die Pipeline selbst kontrollierst und präzise XPath-Steuerung über einen bekannten Baum willst. Greif zu einer AI-Extraktions-API, wenn du Selektoren und Rendering lieber gar nicht selbst pflegen möchtest. In der Praxis nutzen viele Systeme beides — lxml für die strukturierten Feeds, die sie kontrollieren, und einen Extraktionsdienst für die unordentlichen Long-Tail-Seiten, die sie nicht beherrschen.
Was dieses Review nicht getestet hat
Das hier ist ein vorläufiges Review und keine endgültige Scorecard. Deshalb offen, was fehlt.
Alle Timing- und Speicherwerte sind wiederverwendet, stammen von nur einer Plattform (macOS arm64, Python 3.14) und übernehmen die Vorbehalte des ursprünglichen Packs — insbesondere ist das Ergebnis „lxml ist beim reinen Parsen schneller“ gegen die gängige Erwartung und braucht einen Linux-x86_64-Gegencheck. Der Threading-Speedup pro Thread-Parser wurde nicht getestet (dafür wären neue Timings nötig). Ich habe das iterparse-Speicherverhalten auf 300k Records gemessen, aber nicht mit echtem XML im GB-Bereich, nicht mit iterparse auf HTML versus XML und nicht über einen mehrstündigen Soak-Test. lxmls XSLT 1.0, die RelaxNG-/XMLSchema-/DTD-Validierung und EXSLT-Erweiterungen wurden hier überhaupt nicht getestet — ein großer Funktionsumfang, aber außerhalb des Parsing-/Selection-Kerns. Die zweite Tiefenbegrenzung bei 2.045 habe ich beobachtet, aber die exakte libxml2-Rekursionskonstante nicht ermittelt. Getestet wurde nur die stabile Version 6.1.1, nicht die 7.0.0-alpha. Windows, Source-Builds und der frei-threaded 3.14t-Build wurden ebenfalls nicht geprüft. Und innerhalb von XPath selbst habe ich zwar eingebaute Funktionen abgedeckt, aber keine XPath-Variablen, keine benutzerdefinierten Python-Extension-Funktionen und keine Wiederverwendung vorab kompilierter etree.XPath-Objekte.
Fazit
lxml ist nicht das neue, schnelle Tool — und genau deshalb ist es hier die Empfehlung. Es ist ein zwei Jahrzehnte alter libxml2-Binding mit einer vollständigen XPath-1.0-Engine, die keine Mainstream-Python-Alternative sauber erreicht, mit drei klaren Strengestufen beim Parsen samt Error-Log in der Mitte, einem echten Streaming-Parser für Dokumente, die nicht in den Speicher passen, korrekter Behandlung von Multi-Namespaces und Encodings sowie einer vollständig permissiven Lizenz. Die wenigen scharfen Kanten — die Tiefenbegrenzung um ~253 Ebenen und der Threading-Wert beim Shared-Parser — sind dokumentiert, konfigurierbar und jetzt auch erklärt.
Wenn du deine Scraping-Pipeline selbst verantwortest und XPath intensiv nutzt, ist lxml immer noch der Parser, zu dem du greifen solltest. Wenn du Selektoren und Rendering lieber nicht pflegen willst, ist dafür eine AI-Extraktionsschicht wie die Thunderbit API, MCP und CLI da — klare Arbeitsteilung, kein Konkurrenzkampf. In jedem Fall solltest du diese Zahlen als vorläufig ansehen und die Timings auf deiner eigenen Plattform noch einmal prüfen, bevor du sie in einem Design-Dokument zitierst.
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 zu libxml2/libxslt, das Markup in einen editier- und abfragbaren Baum umwandelt. Es lädt keine Seiten, rendert kein JavaScript und übernimmt keine Anti-Bot-Mechanismen; dafür brauchst du die Request-Schicht (z. B. ü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 lxml zwar als Backend-Parser nutzen, bietet aber kein natives XPath. selectolax ist CSS-only und in seinem engen Spezialfall schneller. Wenn deine Selektionslogik Textinhalt, Parent-/Ancestor-Navigation, das Extrahieren von Attributen oder Textknoten oder Count-Prädikate braucht, ist lxmls XPath-Engine die einzige Mainstream-Python-Option, die das direkt ausdrücken kann.
Warum lässt lxml tief verschachtelten Inhalt scheinbar stillschweigend verschwinden?
Der Default-Parser begrenzt die Verschachtelung auf etwa 253 Ebenen — das ist libxml2s DoS-Schutz gegen bösartige Dokumente und kein Bug. Setze huge_tree=True (zum Beispiel lxml.html.HTMLParser(huge_tree=True)), dann werden Tiefen von 300 und 1.000 vollständig wiederhergestellt. Beachte aber die zweite, härtere Rekursionsgrenze um 2.045 Ebenen, die huge_tree nicht aufhebt.
Gibt lxml den GIL beim parallelen Parsen frei? Nur unter den richtigen Bedingungen. Die lxml-FAQ sagt, dass der GIL beim Parsen freigegeben wird, wenn jeder Thread seinen eigenen Parser oder eine Kopie des Default-Parsers nutzt; ein geteilter Parser serialisiert den Zugriff stattdessen. Der wiederverwendete 4-Thread-Speedup von 1,21x beschreibt also den naiven Shared-Parser-Pfad und nicht das Potenzial mit einzelnen Thread-Parsern, das hier nicht gemessen wurde.
Wird lxml im Jahr 2026 noch gepflegt? Ja. Die stabile Version 6.1.1 erschien am 2026-05-18, das Repository wurde zuletzt am 2026-07-02 gepusht, und eine 7.0.0-alpha ist bereits in Arbeit. Mit rund 3.000 GitHub-Stars und einer aktiv gepflegten libxml2 darunter ist das keine Legacy-Bibliothek, sondern weiterhin eine aktuelle, gut unterstützte Lösung.


