Elke paar maanden duikt er weer een snellere HTML-parser op, staan de benchmarks overal en roept iemand dat de oude garde achterhaald is. Tot je vervolgens elk alinea-element wilt selecteren dat een bepaald woord bevat, of de parent van een gematchte node wilt ophalen, en je weer beseft waarom lxml nog altijd openstaat in je andere tabblad.
lxml is een 20 jaar oude binding voor libxml2. Spannend is het niet. Nieuw ook niet. Maar voor één heel specifiek doel — alles wat echte XPath nodig heeft — kan niets anders in mainstream Python hier serieus tegenop. Dit is een praktische review van wat lxml doet, waar het stilletjes wint, en de paar plekken waar de standaardinstellingen je kunnen verrassen als je niet weet dat ze er zijn.
De testopstelling (en waarom de timingcijfers geleend zijn)
Voor deze review zijn twee soorten data gebruikt, en die komen uit twee verschillende bronnen. Dat zeg ik er meteen graag duidelijk bij.
De capability-tests — XPath-gedrag, de twee parser-API's, namespaces, encoding, node-lifecycle — heb ik opnieuw gedraaid op één machine: macOS arm64, Python 3.14.2, lxml 6.1.1, libxml2 2.14.6. Elk getal in die artifacts/raw/*.json-bestanden is door een script berekend, niet handmatig ingevuld. Capability-tests zijn deterministische booleans en enums, dus één run is stabiel — machinebelasting verandert niet of //a/@href een attribuutstring teruggeeft.
De timing- en geheugencijfers zijn niet van deze pack. Die zijn letterlijk overgenomen uit de eerdere selectolax-benchmarkpack — dezelfde machine, dezelfde virtuele omgeving, dezelfde lxml- en libxml2-build, benchmarks van 2026-07-13 — en ik heb ze hier niet opnieuw gedraaid. Dat is bewust. Timingbenchmarks tegelijk draaien met een batch capability-scripts introduceert CPU-contention die de hergebruikte cijfers zou vervuilen, en het zou dubbel werk zijn: lxml was in die pack al een volledig gemeten controlebibliotheek. Door dezelfde benchmarks te hergebruiken blijft alles echt appels-met-appels in plaats van een tweede, subtiel andere meting te introduceren. Dus wanneer je hieronder een milliseconde-cijfer ziet, lees het dan als "zelfde testopstelling, van 2026-07-13", niet als "ik heb dit vandaag opnieuw getimed".
Resultaten krijgen een vertrouwenslabel: single-observation voor de deterministische capability-tests, triple-run voor de hergebruikte timingverdelingen, en hypothesis wanneer ik een mechanisme voorstel dat ik niet afzonderlijk heb geïsoleerd.
lxml in één alinea: wat het echt is
lxml is een Python-binding voor de C-bibliotheken libxml2 en libxslt. Het is een parser en serializer, geen scraper en geen browser — het zet markup om in een boomstructuur die je kunt doorzoeken en aanpassen, en zet die boom daarna weer terug naar bytes. Je krijgt een API die compatibel is met ElementTree, een volledige XPath 1.0-engine, XSLT 1.0 en schema-validatie, onderhouden door Stefan Behnel onder de slogan "de meest feature-rijke en gebruiksvriendelijke bibliotheek voor het verwerken van XML en HTML in de Python-taal".
Zo staat het er, op basis van een GitHub- en PyPI-snapshot van 2026-07-14:
| Veld | Waarde |
|---|---|
| Repo | lxml/lxml |
| Sterren | 3,043 |
| Forks | 620 |
| Open issues | 16 |
| Licentie | BSD-3-Clause |
| Aangemaakt | 2011-02-11 |
| Laatste push | 2026-07-02 |
| Stabiele PyPI-versie | 6.1.1 (2026-05-18) |
| Meegeleverde engine | libxml2 2.14.6 + libxslt 1.1.43 |
Eén ding wil ik alvast uit de weg hebben voordat iemand me van hype beschuldigt: deze review bevat geen geheimen. lxml bestaat al lang genoeg dat elk gedrag hier ergens in de lxml-documentatie, een libxml2-changelog of een launchpad-thread terug te vinden is. Ik heb geen exclusieve, ongedocumenteerde truc ontdekt — en ik ga die ook niet verzinnen. De waarde van wat volgt zit in de structuur, de kwantificering en de focus op lxml zelf; niet in het nieuwsgehalte.
XPath: het ene wat selectolax en BeautifulSoup simpelweg niet hebben
Dit is de hoofdreden om lxml te kiezen, dus ik begin hier.

Ik heb lxml's xpath() door een vooraf vastgelegde matrix van 37 items gehaald — de verwachte uitkomst voor elk geval stond al in de broncode voordat de test begon, zodat ik mezelf niet per ongeluk een ruimhartiger score kon geven. Tien axes, negen predicate-stijlen, tien ingebouwde functies, drie scalaire returntypes en vijf opzettelijke valkuilen met XPath 2.0-syntax die lxml's 1.0-engine juist moet afwijzen.
| Categorie | Dekking | Resultaat |
|---|---|---|
| Axes | child / descendant / parent / ancestor / following-sibling / preceding-sibling / self / attribute / following / preceding | 10/10 geslaagd |
| Predicates | [1] / last() / position()<n / attribuutequality / attribuut bestaat / and / or / genest [.//a] / not() | 9/9 geslaagd |
| Functies | text() / contains() / starts-with() / count() / string-length() / normalize-space() / concat() / substring() / name() / string() | 10/10 geslaagd |
| Returntypes | boolean / number scalars | 3/3 geslaagd |
| Valstrikgevallen | matches() / sequences / if-then-else / except / syntax error | 5/5 correct afgewezen |
De score is 37/37, en juist die valstrik-kolom is het belangrijkste. matches(), sequence-expressies, if/then/else en except zijn allemaal XPath 2.0-syntax, en libxml2's 1.0-engine ondersteunt die niet halfzacht: hij gooit een XPathEvalError en stopt, in plaats van stilzwijgend een verkeerd node-set terug te geven. Dit is dus een perfecte score nadat ik geprobeerd heb het stuk te krijgen, niet een perfecte score die alleen uit makkelijke testcases bestaat. Elk gedrag hier komt precies overeen met wat de lxml XPath-documentatie beschrijft — en dat is precies de bedoeling.
Ik geef één fout van de testharness toe, omdat dit de versie van "37/37" is waar je echt op kunt vertrouwen. Mijn eerste verwachte set voor //div[.//a[@href]] voorspelde twee treffers; de run gaf er één terug. Ik dacht ongeveer dertig seconden dat lxml fout zat, controleerde daarna de fixture en ontdekte dat het tweede element een <footer> was, geen <div> — mijn verwachting was fout, niet de engine. Ik heb de verwachte set aangepast en de vergissing in een broncommentaar laten staan. Dat is de juiste volgorde van schuld: wantrouw eerst je eigen test, pas daarna de 20 jaar oude C-bibliotheek.
XPath vs CSS: wat je letterlijk niet in CSS kunt uitdrukken
De abstracte claim "XPath is krachtiger" verdient een concreet cijfer, dus ik heb het verschil gekwantificeerd. lxml biedt zowel .xpath() als .cssselect() (die CSS onder de motorkap naar XPath vertaalt). Ik nam tien selectiedoelen en keek welke daarvan CSS echt kan uitdrukken.

| Doel | XPath | CSS (cssselect) |
|---|---|---|
Filter op tekstinhoud (contains(text(),"bargain")) | Ja | Geen text-predicate |
Parent selecteren vanuit child (//b/parent::p) | Ja | Geen parent-selector |
Een attribuutwaarde teruggeven (//a/@href) | Ja | Alleen elementen |
Een text-node teruggeven (//p/text()) | Ja | Geen text-nodes |
Ancestor-axis (//td/ancestor::div) | Ja | Geen opwaartse navigatie |
Parent filteren op aantal children (//ul[count(li)=4]) | Ja | Geen count-predicate |
Filteren op tekstlengte (string-length(text())>5) | Ja | Geen lengte-predicate |
nth-child / last-child / adjacent sibling | Ja | Ja (3 basisgevallen) |
Zeven van de tien doelen hebben helemaal geen CSS-equivalent. Filteren op tekstinhoud, omhoog navigeren naar parents en ancestors, een attribuutwaarde of losse text-node als resultaat ophalen, predicates op basis van aantallen — CSS kan het niet. Alleen drie (nth-child, last-child, adjacent sibling) werken in beide. Dat is het gekwantificeerde antwoord op de vraag: "wat win ik eigenlijk met lxml?" selectolax is CSS-only en heeft helemaal geen xpath()-methode, dus die zeven querytypes worden daar multi-step Python-loops of simpelweg onuitvoerbaar. Als je scrapinglogica daarop leunt, is de keuze eigenlijk al gemaakt.
(En ja, de harness pakte me hier een tweede keer: ik voorspelde een lege set voor string-length(text())>5, maar twee strings van zes tekens matchten. De verwachting was fout, niet de tool.)
Drie striktheidsstanden: etree vs recover vs lxml.html
XPath is de reden om lxml te kiezen. De drie niveaus van striktheid zijn de reden om het te blijven gebruiken.

De meeste parsers geven je één gedrag voor kapotte input. lxml geeft je er drie, en ze zijn voorspelbaar genoeg dat ik zes klassen foutieve markup door elk van de drie heb gehaald en vooraf heb vastgelegd hoe elk pad moest reageren.
| Foutieve input | lxml.etree (streng) | etree + recover=True | lxml.html (soepel) |
|---|---|---|---|
Ongeopende tag <root><a>x</root> | geeft fout | herstelt | accepteert |
Verkeerd geneste tags <b><i></b></i> | geeft fout | herstelt | accepteert |
Ongedefinieerde entiteit | geeft fout | herstelt | accepteert |
Losse & (Tom & Jerry) | geeft fout | herstelt | accepteert |
Meerdere roots <a>1</a><b>2</b> | geeft fout | herstelt | accepteert |
| Goed gevormde XML | accepteert | accepteert (0 errors) | accepteert |
Booleaanse attribuut <input disabled> | geeft fout | herstelt | accepteert |
Zeven van de zeven kwamen overeen met de vooraf vastgelegde verwachting. lxml.etree gooit op alle zes foutieve klassen een XMLSyntaxError. Voeg recover=True toe aan dezelfde parser en hij slikt de fouten in en reconstrueert een bruikbare tree — en dat is het onderschatte deel — parser.error_log somt vervolgens elke fout op die hij heeft geslikt. lxml.html accepteert alles zonder klacht.
De classifier die bepaalt of iets "afgewezen", "hersteld" of "geaccepteerd" is, wordt zelf gestuurd door de runtime-lengte van error_log, niet hardcoded. Daardoor wordt een goed gevormd document dat onder recover=True draait correct gelabeld als "accepteert" (lege log) in plaats van "herstelt". Mijn eerste versie van die classifier labelde elk recover=True-resultaat als "herstelt" en plakte daardoor een verkeerd label op schone input; door de echte error_log te lezen was dat opgelost.
Wat dit in de praktijk oplevert: voor strikte validatie waarbij een kapotte feed hard moet falen, gebruik lxml.etree. Voor rommelige echte HTML die je gewoon wilt kunnen verwerken, gebruik lxml.html. En voor het middengebied dat de meeste tools niet aankunnen — "wees soepel, maar vertel me precies wat kapot was zodat ik het kan loggen" — gebruik recover=True en lees de error log. selectolax heeft alleen de soepele stand en verder niets: geen strict mode en geen error log.
iterparse: de streamingstand die selectolax helemaal niet heeft
Dit is een capaciteitskenmerk, geen snelheidsknop. selectolax slurpt alleen een complete string in — er is geen incrementele interface. lxml's iterparse geeft elementen terug zodra ze sluiten, en in combinatie met het klassieke fast_iter-patroon (roep elem.clear() aan en verwijder oude siblings terwijl je verdergaat) blijft het geheugengebruik vlak, hoe groot het document ook is.

Ik heb het geheugengedrag direct gemeten — piek-RSS via ru_maxrss, elk onderwerp in een eigen schoon proces, op 300.000 <record>-elementen met samen ongeveer 15 MB.
| Modus | Piek-RSS delta | Opmerking |
|---|---|---|
iterparse + clear (fast_iter) | ~1-2 MB | wordt onderweg vrijgegeven; vlak ongeacht aantallen |
iterparse zonder clear | ~386 MB | houdt referenties vast; net zo zwaar als volledige load |
etree.parse (volledige load, referentie) | ~386 MB | bekend zwaar; bewijst dat de meter de orde van grootte ziet |
De begrensde modus houdt de piek-RSS-delta rond de 1-2 MB tegenover ~386 MB bij volledige load — een verschil in orde van grootte van 0,3-0,4% — en het eerste record-event vuurt al af voordat het bestand volledig is uitgelezen, dus dit is echt incrementeel en geen nep-streaming. De leerzame regel is de middelste. Draai exact dezelfde iterparse-lus maar sla clear() over, en het geheugen klimt direct terug naar ~386 MB, omdat je referenties naar alles blijft vasthouden. De winst zit dus in clear(), niet in iterparse alleen. Dat de volledige load veel hoger uitkomt dan de begrensde modus bevestigt bovendien dat de RSS-meter het verschil in orde van grootte echt kan zien in plaats van blind te meten. (Deze geheugentest heb ik in deze pack zelf uitgevoerd — het is een footprintmeting, los van de geleende timingcijfers.)
De praktische conclusie: een XML-export van meerdere gigabytes die niet in RAM past, heeft bij selectolax simpelweg geen route. Dan heb je lxml's streaming-parser nodig, of een andere taal.
Namespaces: RSS, SVG en de val van de default namespace
Twaalf namespace-cases, waaronder RSS over drie namespaces, SVG met een default namespace plus xlink, en XML met een default namespace. Alle twaalf slaagden.
lxml haalt //dc:creator/text() uit een RSS-feed precies als ["Alice", "Bob"], lost //atom:link/@href en //content:encoded op over drie verschillende namespaces in hetzelfde document, verwerkt //s:rect en //s:use/@xlink:href in SVG's tweede namespace, splitst Clark-notatie {uri}local-namen met QName en kan via nsmap introspecteren. Dit is het gedocumenteerde, onderhouden gedrag, en het is een hele dimensie waar selectolax niet aan komt, omdat selectolax HTML5-only is en geen willekeurige XML-namespaces verwerkt.
Er is één gedocumenteerde valkuil die je echt moet onthouden. XPath kent het concept default namespace niet. Wijst //book naar een document met xmlns="urn:...", dan krijg je nul treffers — het lege prefix bestaat niet voor XPath, zoals de lxml-documentatie uitlegt. Je moet een kunstmatig prefix binden (//c:book met namespaces={"c": "urn:..."}, waarmee alle drie de treffers werden gevonden) of terugvallen op //*[local-name()='book'] (ook drie). Geen bug — dit is gewoon de XPath-specificatie, correct geïmplementeerd. Maar het verrast iedereen precies één keer.
Echte rommelige pagina's: nauwkeurigheid op 11 echte scrapes
Synthetische tests zijn schoon; het web is dat niet. Ik heb elf echte vastgelegde pagina's hergebruikt uit de fixture-set van de selectolax-pack (van 2026-07-10, read-only) en daar lxml.html op losgelaten, met lxml als hoofdonderwerp.
| Fixture | Grootte | Links | libxml2 herstelde fouten | Strikte XML |
|---|---|---|---|---|
| news_bbc.html | 398 KB | 255 | 4 | ok |
| docs_mdn_array.html | 243 KB | 508 | 0 | gaf fout |
| wiki_scraping.html | 227 KB | 460 | 0 | gaf fout |
| gov_whitehouse.html | 289 KB | 154 | 0 | gaf fout |
| oldstyle_craigslist.html | 561 KB | 351 | 0 | gaf fout |
| forum_reddit.html | 129 KB | 318 | 0 | gaf fout |
| docs_python.html | 80 KB | 341 | 2 | gaf fout |
| ecommerce_books.html | 51 KB | 94 | 0 | gaf fout |
| news_hackernews.html | 35 KB | 229 | 0 | gaf fout |
| ecommerce_webscraper_allinone.html | 16 KB | 35 | 0 | gaf fout |
| spa_quotes_js.html | 6 KB | 5 | 0 | gaf fout |
Alle elf werden geparseerd met lxml.html, en de aantallen links, koppen en afbeeldingen kwamen op alle elf overeen met de hergebruikte lxml-cijfers uit de selectolax-pack — cross-check true. Die overeenkomst zegt me dat het hergebruik echt appels-met-appels is en niet twee verschillende metingen met hetzelfde label.
De nevenbevinding: de strikte XML-parser gaf op tien van de elf pagina's een fout. Echte webpagina's zijn overwegend géén goed gevormde XML, en precies daarom bestaat de HTML-recover-modus van libxml2 om ze te kunnen verwerken. De enige uitzondering was BBC News, gerenderd door Next.js en goed gevormd genoeg om strikte XML-parsing te overleven. Niet alles wat "HTML" heet, heeft de recover-route nodig.
Eén telverschil dat makkelijk misgaat. Op docs_python.html telde //a[@href] (attribuut bestaat) 343, terwijl de selectolax-pack met if n.get("href") (truthy waarde) op 341 uitkwam. De twee extra's zijn lege href=""-links. Dat is een verschil in telconventie — attribuut bestaat versus attribuut is niet-leeg — en geen verschil in lxml-gedrag; de cijfers vallen weer samen zodra je de predicate gelijk trekt. Goed om te weten bij scraping: of lege href's meetellen, is jouw filterkeuze, niet die van de parser.
De dieptelimiet die op een bug lijkt (maar het niet is)
In de selectolax-pack stond dat lxml de diepste content verloor bij 1.000 en 5.000 niveaus geneste <div>-markup, met de formulering dat "lxml stilletjes de diepste content verliest". Ik wilde het mechanisme begrijpen, dus ik draaide de standaardparser tegen huge_tree=True.

| Gevraagde diepte | Standaardparser haalt | huge_tree=True haalt |
|---|---|---|
| 300 | 253 (rest valt weg) | 299 (hersteld) |
| 1000 | 253 (rest valt weg) | 999 (hersteld) |
| 5000 | 253 (rest valt weg) | 2045 (valt nog steeds weg) |
De standaardparser kapt af rond 253 niveaus en laat alles dieper stilletjes verdwijnen. Dat is geen bug — het is libxml2's DoS-bescherming, een nesting-cap rond ongeveer 256 niveaus die voorkomt dat een kwaadaardig document de stack opblaast, en dat staat gedocumenteerd in de lxml-launchpadthread over XML_PARSE_HUGE. Zet huge_tree=True en dieptes van 300 en 1.000 komen volledig terug. Diepte 5.000 haalt met huge_tree echter nog steeds maar 2.045, dus er zit boven die instelbare limiet nog een tweede, hardere libxml2-recursiegrens — en huge_tree heft die niet op.
De actie is dus concreet: wanneer je diepe markup verwerkt uit een bron die je vertrouwt, gebruik lxml.html.HTMLParser(huge_tree=True). Wat deze pack daarbovenop toevoegt is het mechanisme (een veiligheidslimiet, geen datacorruptie), de oplossing (huge_tree) en het feit dat er nog een tweede plafond is waar die oplossing niet aankomt.
DOM lezen en schrijven, serialisatie en encoding
lxml is een volledige read/write-boom, geen read-only extractor, en ik heb het bewerkingsoppervlak case voor case gecontroleerd. Alle acht DOM-operaties slaagden: SubElement, insert, remove, replace, strip_tags (tags verwijderen, tekst laten staan), strip_elements (tags én tekst verwijderen), drop_tree (een lxml.html-specifieke functie) en het twee-slotmodel voor tekst en tail dat beginners vaak op het verkeerde been zet — in <p>head<b>bold</b>tail</p> is p.text "head", b.text "bold" en b.tail "tail".
De serialisatie slaagde vijf van de vijf: tostring in XML- en HTML-modus (HTML laat lege elementen terecht niet zelfsluitend achter), pretty_print, C14N-canonicalisatie (method="c14n", nog een lxml-specifieke functie) en een nette round-trip.
Encoding is het punt waar lxml zich stilletjes onderscheidt. Geef het niet-UTF-8 bytes — "<p>café éè</p>".encode("latin-1") via lxml.html.fromstring — en het haalt café éè er intact uit, zonder U+FFFD-vervangingskarakters en zonder weggegooide bytes. Daarmee reproduceert het direct zijn rol als de "schone referentie" in de selectolax-pack, waar exact dezelfde input door de andere twee engines stilletjes werd beschadigd (Lexbor produceerde vervangingskarakters, Modest gooide bytes volledig weg). lxml's op libxml2 gebaseerde charset-detectie is hier gewoon stabieler.
De keerzijde is strengheid in hoe je een encoding declareert. encoding="latin-1" in een XML-declaratie geeft XMLSyntaxError: Unsupported encoding: latin-1, terwijl de IANA-canonieke encoding="ISO-8859-1" prima parseert en café teruggeeft. libxml2 accepteert alleen canonieke encodernamen, geen aliassen — een detail dat al is gedocumenteerd in launchpad #613302. Irritant als je het niet weet, triviaal zodra je het wel weet.
Tot slot node-lifecycle. Ik heb drie stale-handle-scenario's in geïsoleerde subprocessen getest (een harde crash zou zich tonen als een niet-nul exit): een node vasthouden nadat de tree door garbage collection is opgeruimd, een handle lezen na drop_tree(), en een node gebruiken na remove(). In geen van de gevallen segfaults — lxml houdt de referentie van een node naar zijn tree in stand om use-after-free te voorkomen. Ook hier kreeg lxml dezelfde schone uitslag als selectolax.
snelheid en geheugen (geleend, maar eerlijk daarover)
Alles in deze sectie is hergebruikt uit de selectolax-pack, van 2026-07-13. Deze pack levert zelf nul timingcijfers, en ik zeg dat liever twee keer dan dat je denkt dat ik iets opnieuw heb getimed.
| Dimensie | lxml-waarde | Betekenis |
|---|---|---|
| Pure parse p50 (10 MB) | 77.9 ms | ~33-34% sneller dan selectolax-Lexbor |
| Full parse + extract p50 (1 MB / 10 MB) | 14.18 ms / 172.9 ms | op kleine maten ongeveer gelijk aan Lexbor |
| 100k-node CSS-throughput | 3,002,646 nodes/s | snelste klasse van de drie C-engines |
| 10 MB RSS-delta | 128.9 MB | zuinigste van zes parsers, ~1,7x zuiniger dan BeautifulSoup |
| Koude importstart | 14.1 ms | ~2,3x sneller dan parsel-achtige imports |
De pure-parse- en throughputcijfers zijn sterk, en lxml is het zuinigst met geheugen van de zes gemeten parsers. Wel is er een kanttekening bij threading. De hergebruikte data laat een 4-thread wall-clock speedup zien van slechts 1.21x, gemarkeerd als inconclusief — maar dat is het pad met een gedeelde default parser. De lxml FAQ zegt expliciet dat de GIL alleen tijdens het parsen wordt vrijgegeven als elke thread zijn eigen parser gebruikt (of een gekopieerde default); een gedeelde parser serialiseert de toegang. Ik heb de API-oppervlakte om het goed te doen wel structureel gecontroleerd (XMLParser.copy() bestaat, get/set_default_parser bestaan, XPathEvaluator draagt een interne lock), maar ik heb de speedup per thread-parser niet gemeten — dat zou een nieuwe timingmeting zijn, en die doet deze pack niet. Lees "1.21x" dus als "onder het naïeve gedeelde pad", niet als de threading-grens van lxml.
En één asterisk bij alles: dit zijn single-platformcijfers, macOS arm64. De claim dat lxml's pure parse Lexbor verslaat botst met de gebruikelijke consensus dat de Lexbor-gebaseerde parser het snelst is, dus die wil echt een nieuwe check op Linux x86_64 voordat iemand het als definitief behandelt.
licentie: de saaie overwinning
lxml wordt geleverd onder BSD-3-Clause, en de C-bibliotheken die het meelevert — libxml2 en libxslt — zijn beide MIT. Dat is een volledig permissieve keten zonder copyleft, en dat telt zodra je gaat redistribueren. Ter vergelijking: de selectolax-wheel bundelt LGPL-2.1 Modest en Apache-2.0 Lexbor, dus lxml is de nettere keuze voor distributie in een gesloten product.
Ook praktisch is de installatie prettig: lxml publiceert vooraf gebouwde wheels die libxml2 en libxslt statisch linken, dus pip install lxml vereist op veel systemen geen system-lxml en geen compiler — een andere ervaring dan zelf vanaf bron bouwen.
Waar lxml past — en waar een AI-extractielaag het overneemt
Tijd om de grens scherp te trekken, want dit wordt vaak fout gecategoriseerd. lxml is een parsingbibliotheek. Je krijgt een tree en een uitstekende query-engine, en verder blijft alles rond die tree jouw verantwoordelijkheid: de pagina ophalen, JavaScript renderen, anti-botmaatregelen passeren, de XPath schrijven en onderhouden, en het resultaat structureren. Dat is een andere laag dan een gehoste extractieservice, en de twee zijn minder concurrenten dan buren.
Voor een ontwikkelaar die liever niet de hele fetch-render-select-maintain-stack beheert, is die bovenste laag precies waar iets als Thunderbit zit — en voor deze doelgroep gaat het dan om de API, MCP-server en CLI, niet om de browserextensie. De Thunderbit Open API biedt POST /distill om een pagina om te zetten naar schone Markdown en POST /extract om gestructureerde data te halen op basis van een JSON Schema, met een renderMode-schakelaar en batchjobs voor volume. Dezelfde engine is beschikbaar als MCP-server (thunderbit_suggest_fields, thunderbit_distill, thunderbit_extract) voor agents en coding assistants, en als CLI die je direct vanuit een terminal kunt draaien via npx @thunderbit/thunderbit-cli. Het handelt JS-rendering, anti-bot en CAPTCHA's standaard af en geeft JSON terug die exact op je schema past — wat de laag boven parsing is, geen vervanging ervoor.
Probeer Thunderbit voor webdata-extractie
De keuze is simpel. Kies lxml wanneer je de pipeline zelf beheert en chirurgische XPath-controle wilt over een tree die je begrijpt. Kies een AI-extractie-API wanneer je selectors en rendering liever helemaal niet zelf onderhoudt. In de praktijk gebruiken veel systemen gewoon beide — lxml voor de gestructureerde feeds die ze zelf controleren, en een extractieservice voor de rommelige lange staart van pagina's die ze niet bezitten.
Wat deze review niet heeft getest
Dit is een voorlopige review, geen definitief scorebord, dus dit valt buiten de scope.
Alle timing- en geheugencijfers zijn hergebruikt, single-platform (macOS arm64, Python 3.14) en erven de kanttekeningen van die pack — het resultaat dat "lxml sneller is bij pure parsing" druist in tegen de gangbare consensus en vraagt om een nieuwe check op Linux x86_64. De threading-speedup per thread-parser is niet getest (dat zou nieuwe timing vereisen). Ik heb iterparse-geheugen gemeten op 300k records, maar niet op echte XML van gigabytes, niet op iterparse van HTML versus XML, en niet op een meeruren-soaktest. lxml's XSLT 1.0, RelaxNG / XMLSchema / DTD-validatie en EXSLT-extensies zijn hier helemaal niet getest — een groot capabilities-gebied, maar buiten de kern van parsing en selectie. Ik heb de tweede dieptelimiet op 2.045 waargenomen, maar niet de exacte libxml2-recursieconstante vastgepind. Alleen de stabiele 6.1.1-versie is getest, niet de 7.0.0-alpha. Windows, source builds en de free-threaded 3.14t-build zijn allemaal niet getest. En binnen XPath zelf heb ik ingebouwde functies afgedekt, maar geen XPath-variabelen, custom Python-extensiefuncties of hergebruik van vooraf gecompileerde etree.XPath-objecten.
Oordeel
lxml is niet de snelle nieuwe ster, en juist daarom is het de aanbeveling. Het is een twee decennia oude libxml2-binding met een volledige XPath 1.0-engine waar geen enkele mainstream Python-alternatief aan tipt, drie voorspelbare striktheidsstanden voor parsing met een error log in het midden, een echte streaming parser voor documenten die niet in geheugen passen, correcte multi-namespace- en encoding-afhandeling en een volledig permissieve licentie. De paar scherpe randjes — de dieptelimiet rond 253 niveaus en het cijfer voor een gedeelde parser in threading — zijn gedocumenteerd, configureerbaar en inmiddels verklaard.
Als je je scrapingpipeline zelf beheert en op XPath leunt, is lxml nog steeds de parser waarvoor je kiest. Wil je liever selectors en rendering niet zelf onderhouden, dan is een AI-extractielaag zoals de Thunderbit API, MCP en CLI daarvoor bedoeld — een duidelijke taakverdeling, geen competitie. Hoe dan ook: behandel deze cijfers als voorlopig en controleer de timing op je eigen platform voordat je ze in een design doc citeert.
Probeer Thunderbit voor webdata-extractie Get Started Free
FAQ's
Is lxml een webscraper?
Nee. lxml is een parser en serializer — een Python-binding voor libxml2/libxslt die markup omzet in een bewerkbare, doorzoekbare tree. Het haalt geen pagina's op, rendert geen JavaScript en handelt geen anti-botmaatregelen af; jij levert de request-laag (via requests, httpx, een headless browser of een scrapingservice) en geeft de bytes door aan lxml.
Wanneer gebruik ik lxml in plaats van BeautifulSoup of selectolax? Kies lxml wanneer je XPath nodig hebt. BeautifulSoup kan lxml als backend-parser gebruiken, maar biedt geen native XPath, en selectolax is CSS-only en sneller in zijn smalle niche. Als je selectielogica tekst-inhoud moet filteren, naar parents of ancestors moet navigeren, attribuut- of text-node-extractie nodig heeft of count-predicates gebruikt, dan is lxml's XPath-engine de enige mainstream Python-optie die dat direct uitdrukt.
Waarom laat lxml diep geneste content stilletjes vallen?
De standaardparser begrenst nesting op ongeveer 253 niveaus — libxml2's DoS-bescherming tegen vijandige documenten, geen bug. Zet huge_tree=True (bijvoorbeeld lxml.html.HTMLParser(huge_tree=True)) en dieptes van 300 en 1.000 worden volledig hersteld. Let wel op een tweede, hardere recursiegrens rond 2.045 niveaus die huge_tree niet opheft.
Laat lxml de GIL los bij multithreaded parsing? Alleen onder de juiste omstandigheden. De lxml FAQ zegt dat de GIL wordt vrijgegeven tijdens het parsen wanneer elke thread zijn eigen parser gebruikt of een gekopieerde default parser; een gedeelde parser serialiseert de toegang in plaats daarvan. De hergebruikte speedup van 4 threads van 1.21x weerspiegelt het naïeve gedeelde-parserpad, niet de grens met per-thread-parsers, en die is hier niet gemeten.
Wordt lxml in 2026 nog steeds onderhouden? Ja. De stabiele release 6.1.1 verscheen op 2026-05-18, de repository kreeg zijn laatste push op 2026-07-02 en er is een 7.0.0-alpha in ontwikkeling. Met ongeveer 3.000 GitHub-sterren en een actief onderhouden libxml2 eronder blijft het een actuele, goed ondersteunde bibliotheek en geen legacy-tool.


