Elke paar maanden duikt er weer een snellere HTML-parser op, gaan de benchmark rondes viraal en roept iemand dat de oude garde nu echt overbodig is. Totdat je ineens elk paragraaf wilt selecteren dat een bepaald woord bevat, of de parent van een gevonden node wilt ophalen, en je weer snapt waarom lxml nog altijd in je andere tabblad openstaat.
lxml is een 20 jaar oude binding voor libxml2. Niet spectaculair. Niet nieuw. En voor één heel specifieke taak — alles waarvoor je echte XPath nodig hebt — kan in de gangbare Python-wereld eigenlijk niets ermee concurreren. Dit is een praktijkgerichte review van wat het doet, waar het stilletjes in uitblinkt, en de paar plekken waar de standaardinstellingen je kunnen verrassen als je ze niet kent.
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. Het wordt onderhouden door Stefan Behnel, met als slogan "de meest functionele en eenvoudig te gebruiken bibliotheek voor het verwerken van XML en HTML in de Python-taal".
Zo staat het ervoor, 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 meteen uit de weg ruimen voordat iemand me van hype verdenkt: deze review bevat geen geheime trucjes. lxml is oud genoeg dat elk gedrag hier ergens in de lxml-documentatie, een libxml2-changelog of een Launchpad-thread staat. Ik heb geen exclusieve, ongedocumenteerde hack gevonden, en ik ga er ook geen verzinnen. De waarde van wat volgt zit in de ordening, de metingen en het feit dat lxml zelf het onderwerp is — niet in nieuwswaarde.
De testopzet (en waarom de timingcijfers geleend zijn)
Twee soorten data voeden deze review, en ze komen uit twee verschillende bronnen. Ik ben dus duidelijk over wat van waar komt.
De capaciteitstests — XPath-gedrag, de twee parser-API’s, namespaces, encoding, levenscyclus van nodes — 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 berekend door een script-run, niet met de hand ingevoerd. Capaciteitstests zijn deterministische booleans en enums, dus één run is stabiel — systeembelasting verandert niet of //a/@href een attribuutstring teruggeeft.
De timing- en geheugengetallen zijn niet uit deze set. Die zijn letterlijk hergebruikt uit de eerdere selectolax-benchmarkset — dezelfde machine, dezelfde virtuele omgeving, dezelfde lxml- en libxml2-build, benchmarks per 2026-07-13 — en ik heb ze hier niet opnieuw gedraaid. Dat is bewust. Timingbenchmarks opnieuw draaien terwijl er ook capaciteitsscripts lopen, nodigt uit tot CPU-contending die de hergebruikte cijfers zou vervuilen, en het zou dubbel werk zijn: lxml was in die set al een volledig gemeten referentiebibliotheek. Het hergebruiken van dezelfde benchmark houdt alles echt vergelijkbaar in plaats van een tweede, subtiel andere meting te introduceren. Als je hieronder dus een milliseconde-getal ziet, lees het dan als "dezelfde testopstelling, per 2026-07-13," niet als "ik heb dit vandaag opnieuw getimed."
Bevindingen krijgen een vertrouwenslabel: single-observation voor de deterministische capaciteitstests, triple-run voor de hergebruikte timingdistributies, en hypothesis wanneer ik een mechanisme voorstel dat ik niet apart heb geĂŻsoleerd.
XPath: het ene waar selectolax en BeautifulSoup gewoon niet aan komen
Dit is de hoofdclaim, dus hiermee begin ik.

Ik heb lxml’s xpath() door een vooraf vastgelegde matrix van 37 items gehaald — voor elk geval stond het verwachte resultaat al in de broncode vóór de test draaide, dus ik kon niet per ongeluk soepel beoordelen. Tien assen, negen soorten predicaten, 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 |
|---|---|---|
| Assen | child / descendant / parent / ancestor / following-sibling / preceding-sibling / self / attribute / following / preceding | 10/10 geslaagd |
| Predicaten | [1] / last() / position()<n / attribuutgelijkheid / attribuutbestaan / 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-schaalwaarden | 3/3 geslaagd |
| Vangtestgevallen | matches() / sequenties / if-then-else / except / syntaxfout | 5/5 correct afgewezen |
De score is 37/37, en vooral die vangkolom is relevant. matches(), sequentie-expressies, if/then/else en except zijn allemaal XPath 2.0-syntax, en libxml2’s 1.0-engine ondersteunt die niet half-half — hij gooit XPathEvalError en weigert, in plaats van stilletjes een verkeerde node-set terug te geven. Dit is dus een perfecte score nadat ik hem probeerde stuk te maken, niet een perfecte score op basis van makkelijke vragen. Elk gedrag hier komt exact overeen met wat de lxml XPath-documentatie beschrijft, en dat is precies de bedoeling.
Ik geef één ding toe dat de harness verkeerd had, omdat het de enige versie is van "37/37" die je echt kunt vertrouwen. Mijn eerste verwachte set voor //div[.//a[@href]] voorspelde twee hits; de run gaf er één terug. Ik dacht ongeveer dertig seconden dat lxml fout zat, keek toen nog eens naar de fixture en zag 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, niet de 20 jaar oude C-bibliotheek.
XPath versus CSS: wat je letterlijk niet in CSS kunt uitdrukken
De abstracte claim "XPath is krachtiger" verdient een concreet getal, dus ik heb het verschil gekwantificeerd. lxml biedt zowel .xpath() als .cssselect() aan (waarbij de laatste CSS onder de motorkap naar XPath vertaalt). Ik nam tien selectiedoelen en controleerde welke daarvan CSS ĂĽberhaupt kan uitdrukken.

| Doel | XPath | CSS (cssselect) |
|---|---|---|
Filter op tekstinhoud (contains(text(),"bargain")) | Ja | Geen tekstpredicaat |
Parent selecteren vanuit kind (//b/parent::p) | Ja | Geen parent-selector |
Een attribuutwaarde teruggeven (//a/@href) | Ja | Alleen elementen |
Een tekstnode teruggeven (//p/text()) | Ja | Geen tekstnodes |
Ancestor-as (//td/ancestor::div) | Ja | Geen opwaartse navigatie |
Parent filteren op aantal kinderen (//ul[count(li)=4]) | Ja | Geen count-predicaat |
Filteren op tekstlengte (string-length(text())>5) | Ja | Geen lengtepredicaat |
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 kale tekstnode als resultaat teruggeven, predicaten 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 "wat win ik eigenlijk als ik naar lxml grijp." selectolax is alleen CSS en heeft helemaal geen xpath()-methode, dus die zeven querytypes worden daar multi-step Python-loops of gebeuren simpelweg niet. Als je scrapinglogica op een van die zeven leunt, is de keuze voor jou al gemaakt.
(En ja, de harness pakte me hier nog een tweede keer: ik voorspelde een lege set voor string-length(text())>5, maar twee strings van zes tekens voldeden. De verwachting aangepast, niet de tool.)
Drie versnellingen voor strengheid: etree versus recover versus lxml.html
XPath is de reden om lxml te kiezen. De drie snelheden van strengheid 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 van foutieve markup door elk pad heb gehaald en vooraf heb vastgelegd hoe elk pad zich moest gedragen.
| Foute input | lxml.etree (streng) | etree + recover=True | lxml.html (vergevingsgezind) |
|---|---|---|---|
Niet-afgesloten tag <root><a>x</root> | gooit fout | herstelt | accepteert |
Verkeerd geneste tags <b><i></b></i> | gooit fout | herstelt | accepteert |
Ongedefinieerde entiteit | gooit fout | herstelt | accepteert |
Losse & (Tom & Jerry) | gooit fout | herstelt | accepteert |
Meerdere roots <a>1</a><b>2</b> | gooit fout | herstelt | accepteert |
| Goed gevormde XML | accepteert | accepteert (0 fouten) | accepteert |
Booleaanse attribuut <input disabled> | gooit fout | herstelt | accepteert |
Zeven van de zeven kwamen overeen met de vooraf vastgelegde verwachting. lxml.etree gooit XMLSyntaxError op alle zes foutieve klassen. Voeg recover=True toe aan dezelfde parser en hij slikt de fouten door en reconstrueert een bruikbare boom — en dit is het onderschatte deel — parser.error_log somt daarna elke fout op die hij heeft ingeslikt. lxml.html accepteert alles zonder morren.
De classifier die beslist tussen "gooit", "herstelt" en "accepteert" wordt zelf gestuurd door de runtime-lengte van error_log, niet hardcoded. Daardoor wordt een goed gevormd document dat onder recover=True draait ook correct gelabeld als "accepteert" (lege log) in plaats van "herstelt." Mijn eerste versie van die classifier bestempelde elk recover=True-resultaat als "herstelt" en labelde de schone input foutief; het daadwerkelijke error_log lezen loste dat op.
Wat dit in de praktijk oplevert: strikte validatie waarbij een kapotte feed luid moet falen, gebruik lxml.etree. Vuile HTML uit de echte wereld die je gewoon door de pipeline wilt krijgen, gebruik lxml.html. En de middenweg die de meeste tools niet kunnen bieden — "wees soepel, maar vertel me precies wat er stuk was zodat ik het kan loggen" — gebruik recover=True en lees de errorlog. selectolax heeft alleen die vergevingsgezinde stand en verder niets: geen strikte modus en geen errorlog.
iterparse: de streaming-versnelling die selectolax helemaal niet heeft
Dit is een capaciteitslijn, geen snelheidsknop. selectolax kan alleen een hele string verwerken — er is geen incrementele interface. lxml’s iterparse levert elementen zodra ze sluiten, en in combinatie met het klassieke fast_iter-patroon (roep elem.clear() aan en verwijder onderweg eerdere siblings) houdt het het geheugen vlak, hoe groot het document ook is.

Ik heb het geheugengedrag direct gemeten — piek-RSS via ru_maxrss, elk onderwerp in een eigen vers proces, op 300.000 <record>-elementen met samen ongeveer 26,7 MB (26.744.801 bytes).
| Modus | Piek-RSS-verschil | Opmerking |
|---|---|---|
iterparse + clear (fast_iter) | ~1-2 MB | wordt onderweg vrijgegeven; vlak ongeacht aantal |
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 het piek-RSS-verschil op ongeveer 1-2 MB tegenover ~386 MB bij volledige load — een grootteverschil van 0,3-0,4% — en de eerste record-event vuurt af voordat het bestand zelfs maar klaar is met lezen, dus het is echt incrementeel en geen nepstreaming. De meest leerzame regel is de middelste. Draai exact dezelfde iterparse-lus, maar sla clear() over, en het geheugen schiet weer naar ~386 MB, omdat je verwijzingen naar alles blijft vasthouden. De winst zit in clear(), niet in iterparse zelf. De referentiewaarde van volledige load die veel hoger uitkomt dan de begrensde modus bevestigt ook dat de RSS-meter de kloof echt ziet en niet blind meet. (Deze geheugentest heb ik in deze set zelf gedraaid — het is een footprintmeting, anders dan de geleende timingcijfers.)
De echte versie hiervan: een XML-export van meerdere gigabytes die niet in RAM past, heeft bij selectolax simpelweg geen pad. Dan heb je lxml’s streaming parser nodig, of een andere taal.
Namespaces: RSS, SVG en de valkuil van de default namespace
Twaalf namespace-cases, waaronder RSS met drie namespaces, SVG met een default namespace plus xlink, en XML met een default namespace. Alle twaalf zijn geslaagd.
lxml haalt //dc:creator/text() uit een RSS-feed als exact ["Alice", "Bob"], lost //atom:link/@href en //content:encoded over drie afzonderlijke namespaces in hetzelfde document op, verwerkt //s:rect en //s:use/@xlink:href in SVG’s tweede namespace, splitst Clark-notatie-namen {uri}local met QName, en geeft namespace-informatie terug via nsmap. Dit is gedocumenteerd en 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 geen concept van een default namespace. Richt je //book op een document met xmlns="urn:...", dan krijg je geen hits — de lege prefix bestaat niet voor XPath, zoals de lxml-documentatie uitlegt. Je moet een kunstmatige prefix binden (//c:book met namespaces={"c": "urn:..."}, wat alle drie vond), of terugvallen op //*[local-name()='book'] (ook drie). Geen bug — dit is de XPath-specificatie, netjes geïmplementeerd. Het verrast alleen iedereen precies één keer.
Echte rommelige pagina’s: nauwkeurigheid op 11 werkelijke scrapes
Synthetische tests zijn schoon; het web niet. Ik heb elf echte gecapte pagina’s uit de fixture-set van de selectolax-pack hergebruikt (per 2026-07-10, read-only) en heb lxml.html daarop losgelaten als onderwerp.
| Fixture | Grootte | Links | Door libxml2 herstelde fouten | Strenge XML |
|---|---|---|---|---|
| news_bbc.html | 398 KB | 255 | 4 | ok |
| docs_mdn_array.html | 243 KB | 508 | 0 | gooit fout |
| wiki_scraping.html | 227 KB | 460 | 0 | gooit fout |
| gov_whitehouse.html | 289 KB | 154 | 0 | gooit fout |
| oldstyle_craigslist.html | 561 KB | 351 | 0 | gooit fout |
| forum_reddit.html | 129 KB | 318 | 0 | gooit fout |
| docs_python.html | 80 KB | 341 | 2 | gooit fout |
| ecommerce_books.html | 51 KB | 94 | 0 | gooit fout |
| news_hackernews.html | 35 KB | 229 | 0 | gooit fout |
| ecommerce_webscraper_allinone.html | 16 KB | 35 | 0 | gooit fout |
| spa_quotes_js.html | 6 KB | 5 | 0 | gooit fout |
Alle elf zijn geparseerd met lxml.html, en de aantallen links, koppen en afbeeldingen kwamen op alle elf overeen met de hergebruikte lxml-aantallen uit de selectolax-set — cross-check true. Die overeenstemming zegt me dat hergebruik hier echt appels-met-appels is en niet twee verschillende metingen onder hetzelfde label.
De nevenbevinding: de strenge XML-parser gaf op tien van de elf pagina’s een fout. Echte webpagina’s zijn overwegend niet goed gevormde XML, en precies daarom bestaat de herstelmodus van libxml2 HTML: om ze toch op te eten. De enige uitzondering was BBC News, gerenderd met Next.js en goed gevormd genoeg om strenge XML-parsing te overleven. Niet alles wat “HTML” heet, heeft herstel nodig.
Eén telval waar je gemakkelijk intrapt. Op docs_python.html telde //a[@href] (attribuut bestaat) 343, terwijl de selectolax-set met if n.get("href") (waarheidswaarde) op 341 uitkwam. De twee extra zijn lege href=""-links. Dat is een verschil in telconventie — attribuut bestaat versus attribuut is niet leeg — geen verschil in lxml-gedrag, en de cijfers vallen samen zodra je het predicaat gelijk trekt. Handig om te weten bij web scraping: of lege href’s meetellen, bepaal jij met je filter, niet de parser.
De dieptelimiet die op een bug lijkt (maar het niet is)
De selectolax-set had al vastgelegd dat lxml de diepste content liet vallen bij <div>-markup met 1.000 en 5.000 niveaus nesting, en beschreef dat als "lxml verliest stilletjes de diepste content." Ik wilde het mechanisme weten, dus heb ik de standaardparser tegen huge_tree=True afgezet.

| 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 daaronder stilletjes vallen. Dat is geen bug — het is libxml2’s DoS-verdediging, een nestinglimiet van ongeveer 256 niveaus die voorkomt dat een vijandig document de stack opblaast. Dat staat ook beschreven in de lxml-Launchpad-thread over XML_PARSE_HUGE. Zet huge_tree=True aan en dieptes van 300 en 1.000 komen volledig terug. Maar bij 5.000 niveaus komt zelfs met huge_tree nog maar 2.045 terug — er is dus een tweede, hardere libxml2-recursiegrens boven de configureerbare limiet, en huge_tree haalt die niet weg.
De actie is dus concreet: wanneer je diepe markup parseert uit een bron die je vertrouwt, gebruik lxml.html.HTMLParser(huge_tree=True). Wat deze set toevoegt boven de hergebruikte observatie is het mechanisme (een veiligheidslimiet, geen datacorruptie), de oplossing (huge_tree), en het feit dat er nog een tweede grens bestaat die die oplossing niet wegneemt.
DOM lezen/schrijven, serialisatie, encoding
lxml is een volledige read/write-boom, geen alleen-lezen extractor, en ik heb het bewerkingsoppervlak case voor case gecontroleerd. Alle acht DOM-operaties slaagden: SubElement, insert, remove, replace, strip_tags (tags verwijderen, tekst behouden), strip_elements (tags en hun tekst verwijderen), drop_tree (een exclusieve functie van lxml.html), en het dubbele tekst-/tail-model dat nieuwkomers vaak verrast — in <p>head<b>bold</b>tail</p> is p.text "head", b.text "bold" en b.tail "tail".
Serialisatie was vijf-op-vijf: tostring in XML- en HTML-modus (HTML laat lege elementen correct niet zelfsluitend), pretty_print, C14N-canonisering (method="c14n", nog een lxml-exclusieve functie), en een schone round-trip.
Encoding is waar lxml zich stilletjes onderscheidt. Geef het niet-UTF-8 bytes — "<p>café éè</p>".encode("latin-1") via lxml.html.fromstring — en het herstelt café éè intact, zonder U+FFFD-vervangingskarakters en zonder weggegooide bytes. Dat reproduceert direct zijn rol als de "schone referentie" in de selectolax-set, waar dezelfde input stilletjes corrupt raakte in de andere twee engines (Lexbor gaf vervangingskarakters, Modest liet bytes gewoon vallen). lxml’s libxml2-gestuurde charsetdetectie is hier simpelweg stabieler.
De keerzijde is strengheid over 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 encodingsnamen, geen aliassen — een detail dat al lang geleden is vastgelegd in Launchpad #613302. Irritant als je het niet weet, triviaal zodra je het wel weet.
Tot slot de levenscyclus van nodes. Ik heb drie scenario’s met stale handles in geïsoleerde subprocessen gedraaid (een harde crash zou zichtbaar zijn als een niet-nul exit): een node vasthouden nadat zijn boom door garbage collection is opgeruimd, een handle lezen na drop_tree(), en een node gebruiken na remove(). In geen van de drie een segfault — lxml houdt de verwijzing van een node naar zijn boom levend om use-after-free te voorkomen. Zelfde schone rapportcijfer dat selectolax in deze test kreeg.
Snelheid en geheugen (geleend, en daar eerlijk over)
Alles in deze sectie is hergebruikt uit de selectolax-set, per 2026-07-13. Deze set levert zelf nul timingcijfers op, en ik zeg dat liever twee keer dan dat je denkt dat ik iets opnieuw getimed heb.
| Dimensie | lxml-waarde | Interpretatie |
|---|---|---|
| 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 | grofweg gelijk aan Lexbor bij kleine bestanden |
| 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 |
| Import cold start | 14,1 ms | ~2,3x sneller dan parsel-achtige imports |
De pure-parse- en throughputcijfers zijn sterk, en lxml is van de zes gemeten parsers de zuinigste met geheugen. Maar bij threading hoort wel een kanttekening. De hergebruikte data laten een wall-clock speedup van 1,21x zien bij 4 threads, gemarkeerd als inconclusief — maar dat is het pad met een gedeelde default parser. De lxml-FAQ is expliciet: de GIL wordt tijdens parsing alleen vrijgegeven wanneer 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 heeft een interne lock), maar ik heb de per-thread-parser speedup niet gemeten — dat zou een nieuwe timingmeting zijn, en die produceert deze set niet. Lees "1,21x" dus als "onder het naïeve gedeelde pad", niet als lxml’s threading-plafond.
En nog één asterisk bij dit alles: dit zijn cijfers van één platform, macOS arm64. De claim dat lxml’s pure parse Lexbor verslaat, botst met de gebruikelijke consensus dat de Lexbor-gedreven parser de snelste is, dus die verdient echt een hercontrole op Linux x86_64 voordat iemand het als definitief beschouwt.
Licentie: de saaie overwinning
lxml wordt geleverd onder BSD-3-Clause, en de C-bibliotheken die het meebrengt — libxml2 en libxslt — zijn beide MIT. Dat is een volledig permissieve keten zonder copyleft ergens in de lijn, en dat maakt uit zodra je gaat distribueren. Ter vergelijking: de selectolax-wheel bundelt LGPL-2.1 Modest en Apache-2.0 Lexbor, dus lxml is het nettere verhaal voor gebruik in een gesloten product.
Er is ook een praktisch installvoordeel: lxml publiceert vooraf gebouwde wheels die libxml2 en libxslt statisch linken, dus pip install lxml heeft meestal geen systeemlibxml2 en geen compiler op je machine nodig — een andere ervaring dan bouwen vanaf bron.
Waar lxml past — en waar een AI-extractielaag het overneemt
Tijd om de grens duidelijk te maken, want dit wordt vaak door elkaar gehaald. lxml is een parsingbibliotheek. Het geeft je een boom en een uitstekende query-engine, en alles rond die boom blijft jouw verantwoordelijkheid: de pagina ophalen, JavaScript renderen, anti-botmaatregelen omzeilen, de XPath schrijven en onderhouden, en het resultaat structureren. Dat is een andere laag dan een hosted extractieservice, en die twee zijn eerder buren dan rivalen.
Voor een developer die niet zelf de fetch-render-select-maintain-stack wil beheren, leeft die bovenste laag in iets als Thunderbit — 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 nette Markdown en POST /extract om gestructureerde data op 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 de terminal kunt draaien via npx @thunderbit/thunderbit-cli. Het verwerkt JS-rendering, anti-bot en CAPTCHAs uit de doos en geeft JSON terug dat met het schema overeenkomt — precies de laag boven parsing, niet een vervanging ervan.
Probeer Thunderbit voor webdata-extractie
De afweging is simpel. Kies lxml als je zelf de pipeline beheert en chirurgische XPath-controle wilt over een boom die je begrijpt. Kies een AI-extractie-API als je selectors en rendering liever helemaal niet onderhoudt. In de praktijk gebruiken veel systemen gewoon beide — lxml voor de gestructureerde feeds die ze beheren, een extractieservice voor de rommelige lange staart pagina’s die ze niet beheren.
Wat deze review niet testte
Dit is een voorlopige review, geen definitieve scorekaart, dus dit is wat hij niet afdekt.
Alle timing- en geheugengetallen zijn hergebruikt, afkomstig van één platform (macOS arm64, Python 3.14) en nemen de beperkingen van die set over — het resultaat dat "lxml sneller is bij pure parsing" gaat tegen de gebruikelijke consensus in en verdient een hercontrole op Linux x86_64. De threading-speedup met per-thread parser is niet getest (daarvoor is nieuwe timing nodig). Ik heb iterparse-geheugen gemeten op 300k records, maar niet op echte XML van gigabytes, niet op iterparse voor HTML versus XML, en niet op een meerdaagse soak test. lxml’s XSLT 1.0, RelaxNG / XMLSchema / DTD-validatie en EXSLT-extensies zijn hier volledig onaangeroerd gebleven — een groot capaciteitsvlak, maar buiten de parsing- en selectie-kern. Ik zag de tweede dieptelimiet bij 2.045, maar heb de exacte libxml2-recursieconstante niet vastgesteld. 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 wel afgedekt, maar geen XPath-variabelen, custom Python-extensiefuncties of hergebruik van vooraf gecompileerde etree.XPath-objecten.
Het oordeel
lxml is niet het snelle nieuwe ding, en juist daarom is het zo’n goede aanbeveling. Het is een twee decennia oude libxml2-binding met een volledige XPath 1.0-engine waar geen gangbaar Python-alternatief aan tipt, drie voorspelbare strengheidsstanden met een errorlog in het midden, een echte streaming parser voor documenten die niet in geheugen passen, correcte verwerking van meerdere namespaces en encodings, en een volledig permissieve licentie. De paar scherpe randjes — de dieptelimiet van ongeveer 253 niveaus en het threadingcijfer bij een gedeelde parser — zijn gedocumenteerd, aanpasbaar en nu uitgelegd.
Als je je web scrapingpipeline zelf beheert en op XPath vertrouwt, is lxml nog steeds de parser waar je naar grijpt. Als je selectors en rendering liever niet onderhoudt, dan is dat precies waar een AI-extractielaag zoals de Thunderbit API, MCP en CLI voor bedoeld is — een nette taakverdeling, geen competitie. Hoe dan ook: beschouw deze cijfers als voorlopig en controleer de timing op je eigen platform voordat je ze in een design document citeert.
Probeer Thunderbit voor webdata-extractie Get Started Free
Veelgestelde vragen
Is lxml een webscraper?
Nee. lxml is een parser en serializer — een Python-binding voor libxml2/libxslt die markup omzet in een bewerkbare, doorzoekbare boom. Het haalt geen pagina’s op, rendert geen JavaScript en handelt geen anti-botmaatregelen af; jij levert de requestlaag aan (via requests, httpx, een headless browser of een scrapingservice) en geeft de bytes vervolgens door aan lxml.
Wanneer gebruik ik lxml in plaats van BeautifulSoup of selectolax? Pak lxml wanneer je XPath nodig hebt. BeautifulSoup kan lxml als backend-parser gebruiken, maar biedt geen native XPath, en selectolax is alleen CSS en in zijn smalle niche sneller. Als je selectielogica tekstinhoud, parent- of ancestor-navigatie, extractie van attributen of tekstnodes, of count-predicaten nodig heeft, dan is lxml’s XPath-engine de enige gangbare Python-optie die dat direct uitdrukt.
Waarom laat lxml stilletjes diep geneste content vallen?
De standaardparser begrenst nesting op ongeveer 253 niveaus — libxml2’s DoS-verdediging tegen vijandige documenten, geen bug. Zet huge_tree=True aan (bijvoorbeeld lxml.html.HTMLParser(huge_tree=True)) en het herstelt dieptes van 300 en 1.000 volledig. Let wel op een tweede, hardere recursiegrens rond 2.045 niveaus die huge_tree niet opheft.
Geeft lxml de GIL vrij bij multithreaded parsing? Alleen onder de juiste voorwaarden. Volgens de lxml-FAQ wordt de GIL tijdens parsing vrijgegeven 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 1,21x bij 4 threads weerspiegelt dus het naĂŻeve pad met gedeelde parser, niet de per-thread-ceiling, die hier niet is 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 wordt gewerkt aan een 7.0.0-alpha. Met ongeveer 3.000 GitHub-sterren en een actief onderhouden libxml2 eronder blijft het een actuele, goed ondersteunde bibliotheek en geen erfstuk.


