lxml, passé au crible : le moteur XPath qui surclasse encore les autres parseurs Python

Dernière mise à jour le August 12, 2026
lxml, passé au crible : le moteur XPath qui surclasse encore les autres parseurs Python
Résumé IA
Cette revue de lxml présente la bibliothèque comme la passerelle Python de longue date autour de libxml2 et libxslt, avec un avantage majeur que les parseurs plus récents égalent rarement : un véritable moteur XPath. L’article teste la couverture XPath, les modes de rigidité du parseur, le comportement mémoire du streaming, l’écart d’expressivité entre CSS et XPath, ainsi que les limites de profondeur de libxml2. Il montre qu’lxml est rapide, économe en mémoire et particulièrement puissant pour les workloads XML et HTML qui exigent des axes, des prédicats, des fonctions, du streaming ou une récupération robuste. La revue explique aussi les garde-fous liés à la profondeur de l’arbre et quand huge_tree modifie cette limite.

Tous les quelques mois, un parseur HTML plus rapide débarque, les benchmark s’enchaînent, et quelqu’un annonce que les anciens sont bons à jeter. Puis il faut choisir chaque paragraphe qui contient un mot précis, ou récupérer le parent d’un nœud trouvé, et on se rappelle pourquoi lxml reste ouvert dans l’autre onglet.

lxml est un pont Python vieux de 20 ans vers libxml2. Ce n’est ni tape-à-l’œil ni tout neuf. Et pour une tâche bien précise — tout ce qui demande un vrai XPath — aucun autre outil Python grand public ne joue vraiment dans la même catégorie. Voici un retour de terrain sur ce qu’il fait, là où il gagne sans faire de bruit, et les deux ou trois pièges de ses réglages par défaut si on ne les connaît pas.

lxml en un paragraphe : ce que c’est vraiment

lxml est une bibliothèque Python qui s’appuie sur les C libraries libxml2 et libxslt. C’est un parseur et un sérialiseur, pas un scraper et pas un navigateur — il transforme du balisage en arbre que vous pouvez interroger et modifier, puis reconvertit cet arbre en octets. Il fournit une API compatible avec ElementTree, un moteur XPath 1.0 complet, XSLT 1.0 et la validation de schémas, le tout maintenu par Stefan Behnel sous le slogan « la bibliothèque la plus riche en fonctionnalités et la plus simple d’utilisation pour traiter XML et HTML en Python ».

Voici où il en est, d’après un instantané GitHub et PyPI relevé le 2026-07-14 :

ChampValeur
Dépôtlxml/lxml
Étoiles3,043
Forks620
Tickets ouverts16
LicenceBSD-3-Clause
Création2011-02-11
Dernier push2026-07-02
Version stable PyPI6.1.1 (2026-05-18)
Moteur embarquélibxml2 2.14.6 + libxslt 1.1.43

Avant qu’on m’accuse de survendre la chose, je précise un point : cette analyse ne cache aucun secret. lxml est assez ancien pour que chaque comportement décrit ici apparaisse quelque part dans la documentation lxml, dans le changelog de libxml2 ou dans un fil Launchpad. Je n’ai trouvé aucune astuce obscure et non documentée, et je n’en inventerai pas. L’intérêt de ce qui suit, c’est que c’est structuré, chiffré et organisé autour de lxml comme sujet — pas que ce soit une révélation.

Protocole de test (et pourquoi les chiffres de performance sont empruntés)

Deux familles de données alimentent cette revue, et elles viennent de deux sources différentes ; autant être clair sur ce qui est quoi.

Les tests de capacité — comportement XPath, les deux API de parsing, les espaces de noms, l’encodage, le cycle de vie des nœuds — ont été exécutés à neuf sur une machine : macOS arm64, Python 3.14.2, lxml 6.1.1, libxml2 2.14.6. Chaque chiffre des fichiers artifacts/raw/*.json est calculé par un script lancé à l’exécution, pas saisi à la main. Les tests de capacité étant déterministes (booléens et énumérations), un seul passage suffit à produire un résultat stable — la charge machine ne change pas le fait que //a/@href renvoie ou non une chaîne attributaire.

Les chiffres de temps et d’empreinte mémoire ne viennent pas de ce lot. Ils sont repris tels quels du pack de benchmarks selectolax précédent — même machine, même environnement virtuel, même build de lxml et libxml2, benchmarks arrêtés au 2026-07-13 — et je ne les ai pas relancés ici. C’est volontaire. Relancer des tests de timing en même temps qu’une série de scripts de capacité crée une concurrence CPU qui fausserait les chiffres réutilisés, et ce serait refaire le travail en double : dans ce pack, lxml était déjà une bibliothèque de référence entièrement mesurée. Réutiliser les mêmes benchmarks évite d’introduire une deuxième mesure légèrement différente. Donc, quand vous voyez un temps en millisecondes ci-dessous, lisez-le comme « même banc d’essai, au 2026-07-13 », pas comme « j’ai retimé cela aujourd’hui ».

Chaque résultat est accompagné d’un niveau de confiance : single-observation pour les tests de capacité déterministes, triple-run pour les distributions de timing réutilisées, hypothesis quand j’avance un mécanisme que je n’ai pas isolé.

XPath : la seule chose que selectolax et BeautifulSoup n’ont tout simplement pas

C’est l’argument principal, donc je commence par là.

lxml XPath coverage moat with axes predicates and functions

J’ai soumis xpath() de lxml à une matrice préenregistrée de 37 cas — le résultat attendu de chaque test avait été écrit dans le code avant l’exécution, donc impossible de noter avec indulgence. Dix axes, neuf styles de prédicats, dix fonctions intégrées, trois types de retour scalaires, et cinq cas-pièges volontaires utilisant une syntaxe XPath 2.0 que le moteur 1.0 de lxml doit rejeter.

CatégorieCouvertureRésultat
Axeschild / descendant / parent / ancestor / following-sibling / preceding-sibling / self / attribute / following / preceding10/10 OK
Prédicats[1] / last() / position()<n / égalité d’attribut / existence d’attribut / and / or / [.//a] imbriqué / not()9/9 OK
Fonctionstext() / contains() / starts-with() / count() / string-length() / normalize-space() / concat() / substring() / name() / string()10/10 OK
Types de retourbooléens / scalaires numériques3/3 OK
Cas-piègesmatches() / séquences / if-then-else / except / erreur de syntaxe5/5 rejetés correctement

Le score est de 37/37, et la colonne des pièges est la plus importante. matches(), les expressions séquentielles, if/then/else et except relèvent tous de XPath 2.0, et le moteur 1.0 de libxml2 ne les prend pas en charge à moitié — il lève XPathEvalError et refuse l’expression, plutôt que de renvoyer silencieusement un mauvais ensemble de nœuds. C’est donc un score parfait après tentative de sabotage, pas un score obtenu avec des cas faciles. Chaque comportement ici correspond exactement à ce que décrit la documentation XPath de lxml, et c’est bien l’objectif.

J’admets un point sur lequel le harness s’est trompé, mais c’est justement la version du « 37/37 » à laquelle on peut faire confiance. Mon premier ensemble attendu pour //div[.//a[@href]] prévoyait deux hits ; l’exécution n’en a renvoyé qu’un. J’ai pensé que lxml avait tort pendant une trentaine de secondes, puis j’ai vérifié le fixture et découvert que le second élément était un <footer>, pas un <div> — c’était mon attente qui était fausse, pas le moteur. J’ai corrigé l’ensemble attendu et laissé l’erreur en commentaire dans la source. C’est le bon ordre des responsabilités : soupçonnez d’abord votre test, pas une bibliothèque C vieille de 20 ans.

XPath vs CSS : ce que CSS ne peut littéralement pas exprimer

Le slogan abstrait « XPath est plus puissant » mérite un chiffre concret, alors j’ai quantifié l’écart. lxml vous donne à la fois .xpath() et .cssselect() (ce dernier traduit du CSS vers XPath en interne). J’ai pris dix objectifs de sélection et vérifié lesquels CSS peut réellement exprimer.

XPath expresses seven of ten tasks CSS cannot express

CibleXPathCSS (cssselect)
Filtrer par contenu textuel (contains(text(),"bargain"))OuiPas de prédicat texte
Sélectionner le parent d’un enfant (//b/parent::p)OuiPas de sélecteur parent
Renvoyer la valeur d’un attribut (//a/@href)OuiÉléments uniquement
Renvoyer un nœud texte (//p/text())OuiPas de nœuds texte
Axe ancestor (//td/ancestor::div)OuiPas de navigation vers le haut
Filtrer un parent par nombre d’enfants (//ul[count(li)=4])OuiPas de prédicat de comptage
Filtrer par longueur du texte (string-length(text())>5)OuiPas de prédicat de longueur
nth-child / last-child / sibling adjacentOuiOui (3 cas de base)

Sept des dix cibles n’ont tout simplement aucun équivalent CSS. Filtrage par texte, navigation ascendante vers parents et ancêtres, extraction d’une valeur d’attribut ou d’un nœud texte brut, prédicats basés sur le comptage — CSS ne sait rien exprimer de tout cela. Seuls trois cas (nth-child, last-child, sibling adjacent) fonctionnent dans les deux mondes. Voilà la réponse chiffrée à la question « qu’est-ce que je gagne vraiment avec lxml ? ». selectolax est limité au CSS et ne propose aucune méthode xpath(), donc ces sept types de requêtes doivent être bricolés en plusieurs étapes avec Python, ou ne peuvent pas être faits. Si votre logique de scraping repose sur l’un d’eux, la décision est prise.

(Et oui, le harness m’a piégé une deuxième fois ici : j’avais prévu un ensemble vide pour string-length(text())>5, mais deux chaînes de six caractères correspondaient. J’ai corrigé l’attente, pas l’outil.)

Trois niveaux de rigueur : etree, recover et lxml.html

XPath est la raison de choisir lxml. Les trois modes de strictness sont la raison de le garder.

lxml strictness gears: etree, recover, and lxml.html

La plupart des parseurs vous donnent un seul comportement face à une entrée cassée. lxml en propose trois, et ils sont assez prévisibles pour que j’aie fait passer six classes de balisage invalide dans chacun, avec les attentes préenregistrées pour chaque cas.

Entrée invalidelxml.etree (strict)etree + recover=Truelxml.html (tolérant)
Balise non fermée <root><a>x</root>lève une erreurrécupèreaccepte
Imbrication incorrecte <b><i></b></i>lève une erreurrécupèreaccepte
Entité non définie &nbsp;lève une erreurrécupèreaccepte
Esperluette brute & (Tom & Jerry)lève une erreurrécupèreaccepte
Racines multiples <a>1</a><b>2</b>lève une erreurrécupèreaccepte
XML bien forméaccepteaccepte (0 erreur)accepte
Attribut booléen <input disabled>lève une erreurrécupèreaccepte

Sept cas sur sept ont concordé avec l’attente préenregistrée. lxml.etree lève XMLSyntaxError sur les six classes malformées. Ajoutez recover=True au même parseur, et il avale les erreurs tout en reconstruisant un arbre exploitable — et c’est la partie sous-estimée — parser.error_log liste alors chaque erreur absorbée. lxml.html, lui, accepte tout sans broncher.

Le classifieur qui décide entre « lève », « récupère » et « accepte » s’appuie lui-même sur la longueur réelle de error_log, pas sur une valeur codée en dur ; c’est ce qui permet à un document bien formé passé avec recover=True d’être correctement classé « accepte » (journal vide) plutôt que « récupère ». Ma première version de ce classifieur étiquetait tout résultat avec recover=True comme « récupère » et malclassait l’entrée propre ; lire le error_log réel a corrigé cela.

En pratique, cela vous donne : validation stricte quand un flux cassé doit échouer bruyamment, lxml.etree ; HTML réel sale qu’il faut simplement réussir à parser, lxml.html ; et le cas intermédiaire que la plupart des outils ne savent pas traiter — « soyez tolérant, mais dites-moi précisément ce qui était cassé pour que je le journalise » — recover=True avec lecture du journal d’erreurs. selectolax n’a que le mode tolérant, et rien d’autre : ni mode strict, ni journal d’erreurs.

iterparse : le mode streaming que selectolax n’a pas du tout

Ici, on parle de capacité, pas d’un simple réglage de vitesse. selectolax ingère uniquement une chaîne entière — il n’a pas d’interface incrémentale. iterparse de lxml renvoie les éléments au fur et à mesure qu’ils se ferment, et associé au pattern fast_iter classique (faire elem.clear() et supprimer les frères précédents au passage), il garde la mémoire stable quelle que soit la taille du document.

lxml iterparse streams 300K records with about 1-2 MB RSS

J’ai mesuré directement le comportement mémoire — RSS max via ru_maxrss, chaque sujet dans un processus vierge, sur 300 000 éléments <record> représentant environ 26,7 MB (26,744,801 octets).

ModeVariation RSS maxNotes
iterparse + clear (fast_iter)~1-2 MBlibéré au fil de l’eau ; stable quel que soit le volume
iterparse sans clear~386 MBgarde les références ; aussi lourd qu’un chargement complet
etree.parse (chargement complet, référence)~386 MBlourd par nature ; prouve que le compteur voit bien l’ordre de grandeur

Le mode borné maintient la hausse du RSS max autour de 1-2 MB face à ~386 MB en chargement complet — soit un écart de l’ordre de 0,3 à 0,4 % — et le premier événement de record arrive avant même la fin de lecture du fichier, donc il s’agit bien d’un flux incrémental réel, pas d’un faux streaming. La ligne la plus instructive est celle du milieu. Lancez la même boucle iterparse mais sans clear(), et la mémoire remonte à ~386 MB, parce que vous conservez des références à tout. Le gain vient de clear(), pas de iterparse tout seul. Le fait que la référence de chargement complet soit nettement plus haute que le mode borné confirme aussi que le mesureur RSS voit bien l’écart d’ampleur au lieu de lire dans le vide. (Ce test mémoire, je l’ai bien refait dans ce pack — c’est une mesure d’empreinte, distincte des timings réutilisés.)

La version terrain de cela : un export XML de plusieurs gigaoctets qui ne tient pas en RAM n’a aucune alternative avec selectolax. Il faut le parseur streaming de lxml, ou changer de langage.

Espaces de noms : RSS, SVG et le piège de l’espace de noms par défaut

Douze cas d’espaces de noms, couvrant RSS sur trois namespaces, SVG avec un namespace par défaut plus xlink, et du XML à namespace par défaut. Les douze ont réussi.

lxml extrait //dc:creator/text() d’un flux RSS exactement sous la forme ["Alice", "Bob"], résout //atom:link/@href et //content:encoded à travers trois namespaces distincts dans le même document, gère //s:rect et //s:use/@xlink:href dans le second namespace de SVG, découpe les noms au format Clark {uri}local avec QName, et expose les namespaces via nsmap. C’est le comportement maintenu et documenté, et c’est une dimension entière que selectolax ne couvre pas, parce qu’il est limité à HTML5 et ne traite pas les namespaces XML arbitraires.

Il existe un piège documenté qu’il vaut la peine de mémoriser. XPath n’a pas de notion d’espace de noms par défaut. Si vous appliquez //book à un document déclarant xmlns="urn:...", vous obtenez zéro résultat — le préfixe vide n’existe pas pour XPath, comme le rappelle la documentation lxml. Vous devez soit associer un préfixe artificiel (//c:book avec namespaces={"c": "urn:..."}, ce qui a trouvé les trois), soit retomber sur //*[local-name()='book'] (également trois). Ce n’est pas un bug — c’est la spécification XPath, appliquée fidèlement. Cela surprend juste tout le monde une fois.

Pages réelles malpropres : fidélité sur 11 scrapes réels

Les tests synthétiques sont propres ; le web ne l’est pas. J’ai réutilisé onze vraies pages capturées dans le jeu de fixtures du pack selectolax (au 2026-07-10, en lecture seule) et j’ai soumis lxml.html à ces pages en prenant lxml comme sujet.

FixtureTailleLienserreurs récupérées par libxml2XML strict
news_bbc.html398 KB2554ok
docs_mdn_array.html243 KB5080erreur
wiki_scraping.html227 KB4600erreur
gov_whitehouse.html289 KB1540erreur
oldstyle_craigslist.html561 KB3510erreur
forum_reddit.html129 KB3180erreur
docs_python.html80 KB3412erreur
ecommerce_books.html51 KB940erreur
news_hackernews.html35 KB2290erreur
ecommerce_webscraper_allinone.html16 KB350erreur
spa_quotes_js.html6 KB50erreur

Les onze ont été parsés avec lxml.html, et les comptes de liens, de titres et d’images coïncident avec les comptes lxml réutilisés du pack selectolax sur les onze cas — vérification croisée true. C’est cet accord qui me dit que la réutilisation est bien comparable et qu’il ne s’agit pas de deux mesures différentes portant la même étiquette.

Autre constat : le parseur XML strict a levé une erreur sur dix des onze pages. Les vraies pages web ne sont presque jamais du XML bien formé, ce qui explique précisément pourquoi le mode de récupération HTML de libxml2 existe. La seule exception était BBC News, rendue par Next.js et suffisamment propre pour passer le parsing XML strict. Tout ce qui s’appelle « HTML » n’a pas forcément besoin du mode récupération.

Un point de comptage est facile à rater. Sur docs_python.html, //a[@href] (existence d’attribut) a compté 343, alors que le if n.get("href") du pack selectolax (valeur vraie) comptait 341. Les deux liens supplémentaires ont un href="" vide. C’est une différence de convention de comptage — attribut présent versus attribut non vide — pas une différence de comportement de lxml, et les comptes s’alignent dès qu’on harmonise le prédicat. Bon à savoir quand vous scrapez : le fait de compter les href vides dépend de votre filtre, pas du parseur.

La limite de profondeur qui ressemble à un bug (mais n’en est pas un)

Le pack selectolax avait noté que lxml perdait le contenu le plus profond sur des balisages <div> imbriqués à 1 000 et 5 000 niveaux, en parlant de « lxml perd silencieusement le contenu le plus profond ». Je voulais comprendre le mécanisme, alors j’ai testé le parseur par défaut avec huge_tree=True.

lxml default depth guard around 253 levels and huge_tree to 2045

Profondeur demandéeProfondeur atteinte par le parseur par défautProfondeur atteinte avec huge_tree=True
300253 (le reste est perdu)299 (récupéré)
1000253 (le reste est perdu)999 (récupéré)
5000253 (le reste est perdu)2045 (reste perdu)

Le parseur par défaut tronque vers 253 niveaux et supprime silencieusement tout ce qui est plus profond. Ce n’est pas un bug — c’est la défense anti-DoS de libxml2, une limite d’imbrication d’environ 256 niveaux destinée à empêcher un document hostile de faire exploser la pile, et c’est documenté dans le fil Launchpad de lxml sur XML_PARSE_HUGE. Activez huge_tree=True et les profondeurs 300 et 1 000 reviennent intégralement. En revanche, à 5 000 niveaux, on n’atteint encore que 2 045 même avec huge_tree — il existe un second plafond de récursion plus dur, au-dessus de la limite configurable, et huge_tree ne le supprime pas.

L’action concrète est donc claire : quand vous parsez un balisage très profond provenant d’une source de confiance, utilisez lxml.html.HTMLParser(huge_tree=True). Ce que ce pack ajoute à l’observation réutilisée, c’est le mécanisme (une limite de sécurité, pas une corruption de données), la correction (huge_tree), et le fait qu’il existe un second plafond que ce réglage n’atteint pas.

DOM en lecture/écriture, sérialisation, encodage

lxml est un arbre complet en lecture/écriture, pas un extracteur en lecture seule, et j’ai vérifié la surface d’édition cas par cas. Les huit opérations DOM ont réussi : SubElement, insert, remove, replace, strip_tags (supprime les balises tout en conservant leur texte), strip_elements (supprime les balises et leur texte), drop_tree (spécifique à lxml.html), ainsi que le modèle à deux emplacements text/tail qui piège les débutants — dans <p>head<b>bold</b>tail</p>, p.text vaut "head", b.text vaut "bold" et b.tail vaut "tail".

La sérialisation a réussi cinq sur cinq : tostring en mode XML et HTML (le HTML laisse correctement les éléments vides non autofermés), pretty_print, la canonisation C14N (method="c14n", autre exclusivité lxml) et un aller-retour propre.

L’encodage est le domaine où lxml se distingue discrètement. Donnez-lui des octets non UTF-8 — "<p>café éè</p>".encode("latin-1") via lxml.html.fromstring — et il restitue café éè intact, sans caractères de remplacement U+FFFD, sans octets perdus. Cela reproduit directement son rôle de référence propre dans le pack selectolax, où la même entrée était silencieusement corrompue par les deux autres moteurs (Lexbor produisait des caractères de remplacement, Modest supprimait carrément des octets). La détection d’encodage de libxml2 est tout simplement plus stable ici.

L’autre face de la médaille, c’est la rigueur sur la façon dont vous déclarez un encodage. encoding="latin-1" dans une déclaration XML provoque XMLSyntaxError: Unsupported encoding: latin-1, tandis que le nom canonique IANA encoding="ISO-8859-1" passe sans problème et renvoie café. libxml2 n’accepte que les noms canoniques, pas les alias — un détail documenté dès Launchpad #613302. Pénible si vous l’ignorez, trivial une fois connu.

Enfin, le cycle de vie des nœuds. J’ai testé trois scénarios de poignée obsolète dans des sous-processus isolés (un crash franc apparaîtrait comme une sortie non nulle) : conserver un nœud après la collecte de son arbre par le GC, relire une poignée après drop_tree(), et utiliser un nœud après remove(). Aucun segfault dans ces cas — lxml garde la référence du nœud vers son arbre pour éviter les use-after-free. Même constat rassurant que selectolax sur ce test.

Vitesse et mémoire (empruntées, et assumées comme telles)

Tout ce qui suit est repris du pack selectolax, arrêté au 2026-07-13. Ce pack ne produit aucun chiffre de timing propre, et je préfère le dire deux fois plutôt que vous laissiez croire que j’ai retimé quoi que ce soit.

DimensionValeur lxmlLecture
p50 parse pur (10 MB)77.9 ms~33-34 % plus rapide que selectolax-Lexbor
p50 parse complet + extraction (1 MB / 10 MB)14.18 ms / 172.9 msà peu près à égalité avec Lexbor sur les petites tailles
Débit CSS sur 100k nœuds3,002,646 nœuds/scatégorie la plus rapide des trois moteurs C
Delta RSS sur 10 MB128.9 MBle plus économe des six parseurs, ~1,7x plus léger que BeautifulSoup
Démarrage à froid de l’import14.1 ms~2,3x plus rapide que les imports de type parsel

Les chiffres de parsing pur et de débit sont solides, et lxml est le plus frugal en mémoire des six parseurs mesurés. Il faut cependant nuancer l’histoire du multithreading. Les données réutilisées montrent un gain wall-clock de seulement 1,21x sur 4 threads, classé non concluant — mais cela correspond à un chemin avec parseur partagé par défaut. La FAQ lxml précise explicitement que le GIL n’est libéré pendant le parsing que si chaque thread utilise son propre parseur (ou une copie du parseur par défaut) ; un parseur partagé sérialise l’accès. J’ai vérifié structurellement que l’API nécessaire est bien là (XMLParser.copy() existe, get/set_default_parser existent, XPathEvaluator porte un verrou interne), mais je n’ai pas mesuré le gain avec un parseur par thread — cela exigerait un nouveau benchmark, et ce pack n’en produit pas. Donc lisez le 1.21x comme « sous le chemin naïf avec parseur partagé », pas comme le plafond de lxml en multithread.

Et une réserve sur l’ensemble : ce sont des chiffres mono-plateforme, macOS arm64. L’affirmation selon laquelle le parse pur de lxml dépasse Lexbor va à contre-courant du consensus habituel, qui considère le parseur basé sur Lexbor comme le plus rapide ; elle mérite donc réellement une vérification sur Linux x86_64 avant d’être considérée comme définitive.

Licence : la victoire sans bruit

lxml est distribué sous BSD-3-Clause, et les bibliothèques C qu’il embarque — libxml2 et libxslt — sont toutes deux sous MIT. C’est une chaîne entièrement permissive, sans copyleft nulle part, ce qui compte dès que vous redistribuez. À titre de comparaison, le wheel selectolax embarque LGPL-2.1 Modest et Apache-2.0 Lexbor, donc lxml est plus simple à intégrer dans un produit fermé.

Il y a aussi un avantage d’installation très concret : lxml publie des wheels précompilés qui lient statiquement libxml2 et libxslt, si bien que pip install lxml ne nécessite généralement ni libxml2 système ni compilateur sur votre machine — une expérience différente d’une compilation depuis les sources.

Où lxml s’insère — et où une couche d’extraction IA prend le relais

Il est important de bien fixer la frontière, car l’erreur de catégorie est facile. lxml est une bibliothèque de parsing. Il vous fournit un arbre et un excellent moteur de requêtes, et tout ce qui entoure cet arbre reste votre responsabilité : récupérer la page, rendre le JavaScript, contourner l’anti-bot, écrire et maintenir le XPath, puis structurer le résultat. C’est une autre couche qu’un service d’extraction hébergé, et les deux ne sont pas tant des rivaux que des voisins.

Pour un développeur qui ne veut pas porter toute la pile fetch-render-select-maintain, cette couche supérieure est l’endroit où vit quelque chose comme Thunderbit — et pour ce public, il s’agit de l’API, du serveur MCP et du CLI, pas de l’extension navigateur. Le Thunderbit Open API expose POST /distill pour transformer une page en Markdown propre et POST /extract pour extraire des données structurées selon un JSON Schema, avec un commutateur renderMode et des jobs batch pour le volume. Le même moteur existe aussi comme serveur MCP (thunderbit_suggest_fields, thunderbit_distill, thunderbit_extract) pour les agents et assistants de code, ainsi que comme CLI exécutable directement depuis un terminal via npx @thunderbit/thunderbit-cli. Il gère le rendu JS, l’anti-bot et les CAPTCHA dès le départ et renvoie du JSON conforme au schéma — c’est donc la couche au-dessus du parsing, pas un substitut.

Essayer Thunderbit pour l’extraction de données web

Le principe est simple : utilisez lxml quand vous maîtrisez le pipeline et voulez un contrôle XPath chirurgical sur un arbre que vous comprenez. Tournez-vous vers une API d’extraction IA quand vous ne voulez pas gérer les sélecteurs ni le rendu. Beaucoup de systèmes réels utilisent les deux : lxml pour les flux structurés qu’ils contrôlent, un service d’extraction pour les pages longues et sales qu’ils ne contrôlent pas.

Ce que cette revue n’a pas testé

Il s’agit d’une revue provisoire, pas d’un verdict final, donc voici ce qu’elle ne couvre pas.

Tous les chiffres de timing et de mémoire sont réutilisés, mono-plateforme (macOS arm64, Python 3.14) et héritent des réserves de ce pack — le résultat selon lequel « lxml est plus rapide en parsing pur » va à contre-courant du consensus et demande une nouvelle vérification sur Linux x86_64. Le gain de threading avec un parseur par thread n’a pas été testé (il faudrait un nouveau benchmark). J’ai mesuré la mémoire d’iterparse sur 300k enregistrements, mais pas sur du vrai XML à l’échelle du gigaoctet, ni iterparse sur HTML versus XML, ni une tenue sur plusieurs heures. XSLT 1.0 de lxml, la validation RelaxNG / XMLSchema / DTD et les extensions EXSLT n’ont pas été testées ici — un vaste périmètre de capacités, mais hors du cœur parsing-sélection. J’ai observé le second plafond de profondeur à 2 045 sans isoler la constante de récursion exacte de libxml2. Seule la version stable 6.1.1 a été testée, pas l’alpha 7.0.0. Windows, les builds depuis les sources et la version 3.14t free-threaded n’ont pas été testés. Et dans XPath lui-même, j’ai couvert les fonctions intégrées mais pas les variables XPath, les fonctions d’extension Python personnalisées, ni la réutilisation d’objets etree.XPath précompilés.

Verdict

lxml n’est pas la nouveauté rapide du moment, et c’est précisément pour ça qu’on le recommande. C’est un pont libxml2 vieux de deux décennies, avec un moteur XPath 1.0 complet qu’aucune alternative Python grand public n’égale, trois niveaux de rigueur de parsing prévisibles avec un journal d’erreurs au milieu, un vrai parseur streaming pour les documents qui ne tiennent pas en mémoire, une gestion correcte des espaces de noms multiples et de l’encodage, et une licence totalement permissive. Les quelques angles vifs — la limite de profondeur d’environ 253 niveaux et le chiffre de threading avec parseur partagé — sont documentés, configurables et maintenant expliqués.

Si vous maîtrisez votre pipeline de scraping et vous appuyez sur XPath, lxml reste le parseur de référence. Si vous préférez ne pas maintenir vous-même les sélecteurs et le rendu, c’est justement le rôle d’une couche d’extraction IA comme l’API Thunderbit, le MCP et le CLI — une répartition claire des tâches, pas une compétition. Dans tous les cas, considérez ces chiffres comme provisoires et revérifiez les temps sur votre propre plateforme avant de les citer dans une note de conception.

Essayer Thunderbit pour l’extraction de données web Get Started Free

FAQ

lxml est-il un web scraper ? Non. lxml est un parseur et un sérialiseur — un pont Python vers libxml2/libxslt qui transforme le balisage en arbre éditable et interrogeable. Il ne récupère pas les pages, ne rend pas le JavaScript et ne gère pas l’anti-bot ; c’est vous qui fournissez la couche de requête (via requests, httpx, un navigateur headless ou un service de scraping) avant de passer les octets à lxml.

Quand faut-il utiliser lxml plutôt que BeautifulSoup ou selectolax ? Choisissez lxml quand vous avez besoin de XPath. BeautifulSoup peut utiliser lxml comme backend de parsing, mais n’expose aucun XPath natif, et selectolax est limité au CSS — plus rapide dans son créneau étroit. Si votre logique de sélection doit filtrer selon le texte, remonter vers un parent ou un ancêtre, extraire un attribut ou un nœud texte, ou utiliser des prédicats de comptage, le moteur XPath de lxml est la seule option Python grand public qui les exprime directement.

Pourquoi lxml supprime-t-il silencieusement le contenu profondément imbriqué ? Son parseur par défaut limite l’imbrication à environ 253 niveaux — une défense libxml2 contre les documents hostiles, pas un bug. Activez huge_tree=True (par exemple lxml.html.HTMLParser(huge_tree=True)) et il récupère intégralement des profondeurs de 300 et 1 000. À noter qu’il existe un second plafond de récursion plus dur, autour de 2 045 niveaux, que huge_tree ne supprime pas.

lxml libère-t-il le GIL lors du parsing multithread ? Oui, mais seulement dans les bonnes conditions. La FAQ lxml indique que le GIL est libéré pendant le parsing lorsque chaque thread utilise son propre parseur ou une copie du parseur par défaut ; un parseur partagé sérialise l’accès. Le gain réutilisé de 1,21x sur 4 threads reflète le chemin naïf avec parseur partagé, pas le plafond avec un parseur par thread, qui n’a pas été mesuré ici.

lxml est-il encore maintenu en 2026 ? Oui. La version stable 6.1.1 est sortie le 2026-05-18, le dépôt a reçu son dernier push le 2026-07-02, et une alpha 7.0.0 est en cours. Avec environ 3 000 étoiles GitHub et une libxml2 activement maintenue en dessous, c’est encore une bibliothèque actuelle et bien supportée, pas un héritage abandonné.

Ke
Ke
CTO chez Thunderbit | Data Scientist senior et expert en ML Forte d’une expérience de près de dix ans en machine learning et en data science, Ke Shen est diplômé de Columbia University et ancien Data Scientist senior chez Walmart Labs. Grâce à une expertise approfondie, reconnue par ses pairs, en Python, R, Java et statistiques, il partage des analyses éprouvées sur le passage d’algorithmes d’IA complexes de la théorie à une architecture prête pour la production.
Table des matières
Thunderbit · Agent IA de données web

Extrayez des données de n'importe quelle page en 1 clic

Approuvé par plus de 250 000 utilisateurs
formule gratuite disponible
De la page web au tableur
Décrivez ce dont vous avez besoin — l'agent IA de Thunderbit l'extrait et l'exporte vers Excel, Google Sheets, Airtable ou Notion. Gratuit pour commencer.
Chrome Store Rating
PRODUCT HUNT#1 Product of the Week